portta 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/cli.js +77013 -0
  2. package/dist/documentation.json +1 -0
  3. package/dist/runtime/.env.example +440 -0
  4. package/dist/runtime/VERSION +1 -0
  5. package/dist/runtime/config/traefik/dynamic/auth.example.yaml.disabled +24 -0
  6. package/dist/runtime/config/traefik/dynamic/local-tls.yaml +11 -0
  7. package/dist/runtime/config/traefik/dynamic/middlewares.yaml +35 -0
  8. package/dist/runtime/config/traefik/dynamic/portta-aliases.yaml +9 -0
  9. package/dist/runtime/config/traefik/dynamic/portta-auth.yaml +19 -0
  10. package/dist/runtime/config/traefik/dynamic/portta-shares.yaml +10 -0
  11. package/dist/runtime/config/traefik/dynamic/tcp.yaml +23 -0
  12. package/dist/runtime/docker/compose/attach/host.yaml +24 -0
  13. package/dist/runtime/docker/compose/attach/tailscale.yaml +70 -0
  14. package/dist/runtime/docker/compose/compose.yaml +214 -0
  15. package/dist/runtime/docker/compose/features/auth-build.yaml +19 -0
  16. package/dist/runtime/docker/compose/features/auth-dev.yaml +20 -0
  17. package/dist/runtime/docker/compose/features/cloudflare-tunnel.yaml +69 -0
  18. package/dist/runtime/docker/compose/features/dashboard-tailscale.yaml +21 -0
  19. package/dist/runtime/docker/compose/features/dashboard.yaml +21 -0
  20. package/dist/runtime/docker/compose/features/db.yaml +63 -0
  21. package/dist/runtime/docker/compose/features/panel-domain.yaml +42 -0
  22. package/dist/runtime/docker/compose/features/panel-public.yaml +47 -0
  23. package/dist/runtime/docker/compose/features/panel-webhook.yaml +46 -0
  24. package/dist/runtime/docker/compose/features/tcp-tailscale.yaml +30 -0
  25. package/dist/runtime/docker/compose/features/tcp.yaml +40 -0
  26. package/dist/runtime/docker/compose/features/web-bind.yaml +19 -0
  27. package/dist/runtime/docker/compose/features/web-build.yaml +21 -0
  28. package/dist/runtime/docker/compose/features/web-dev.yaml +53 -0
  29. package/dist/runtime/docker/compose/features/web-vpn.yaml +24 -0
  30. package/dist/runtime/docker/compose/features/web.yaml +283 -0
  31. package/dist/runtime/docker/compose/profiles/local-tls.yaml +23 -0
  32. package/dist/runtime/docker/compose/profiles/local.yaml +15 -0
  33. package/dist/runtime/docker/compose/profiles/public.yaml +19 -0
  34. package/dist/runtime/docker/compose/profiles/remote-tls-dns.yaml +26 -0
  35. package/dist/runtime/docker/compose/profiles/remote-tls-http.yaml +32 -0
  36. package/dist/runtime/docker/compose/profiles/remote-tls.yaml +31 -0
  37. package/dist/runtime/docker/compose/profiles/remote.yaml +23 -0
  38. package/dist/runtime/docker/images/README.md +12 -0
  39. package/dist/runtime/docker/images/apply/Dockerfile +45 -0
  40. package/dist/runtime/docker/images/toolbox/Dockerfile +38 -0
  41. package/dist/runtime/scripts/lib/runner-exec.sh +189 -0
  42. package/dist/runtime/templates/README.md +43 -0
  43. package/dist/runtime/templates/overlays/01-single-web.yaml +23 -0
  44. package/dist/runtime/templates/overlays/02-web-api.yaml +30 -0
  45. package/dist/runtime/templates/overlays/03-web-api-postgres.yaml +33 -0
  46. package/dist/runtime/templates/overlays/04-web-api-postgres-redis.yaml +30 -0
  47. package/dist/runtime/templates/overlays/05-multiple-apis.yaml +37 -0
  48. package/dist/runtime/templates/overlays/06-monorepo.yaml +63 -0
  49. package/dist/runtime/templates/overlays/07-worktree.env +47 -0
  50. package/dist/runtime/templates/overlays/08-nonstandard-port.yaml +41 -0
  51. package/dist/runtime/templates/overlays/09-tcp-routing.yaml +70 -0
  52. package/dist/runtime/templates/overlays/10-mailpit.yaml +24 -0
  53. package/dist/runtime/templates/overlays/11-rustfs.yaml +25 -0
  54. package/dist/runtime/templates/project/PORTTA.md +107 -0
  55. package/package.json +53 -0
@@ -0,0 +1 @@
1
+ {"schemaVersion":1,"identity":{"version":"0.8.0","revision":"fa91afffa51987ae1c296624a0f9f667355ea996","hash":"9c7ebbe1fbb7937ba4c985f3970844c056b2c9fb79f9621b81384e6decd3ba22"},"pages":[{"slug":"install","title":"Install Portta","description":"Install Portta and choose how to reach the panel.","source":"docs/product/getting-started/install.md","audience":"user","section":"Getting Started","category":"","url":"/docs/install","markdown":"# Install Portta\n\nInstall the gateway and panel from the published npm package. No repository\ncheckout is needed.\n\n## Requirements\n\n- Node 22.12 or newer, including npm;\n- Docker Engine 24 or newer with Compose v2;\n- a POSIX host and shell.\n\nThe installer does not install those prerequisites or request elevated\nprivileges.\n\n## Install\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/fabioassuncao/portta/main/install.sh | sh\n```\n\nThe script hands control to `npx portta setup`. To install a specific published\nversion or choose a directory:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/fabioassuncao/portta/main/install.sh | \\\n PORTTA_VERSION=0.8.0 sh -s -- --dir /opt/portta --yes\n```\n\nUse `--dry-run` first when automating:\n\n```bash\nnpx portta setup --dir /opt/portta --profile local --dry-run --json\nnpx portta setup --dir /opt/portta --profile local --yes\n```\n\n## Installation directory\n\nThe directory contains the current release runtime and the host's state:\n\n```text\n<installation>/\n├── VERSION\n├── .env\n├── bin/portta\n├── docker/compose/\n├── docker/images/\n├── templates/\n├── config/\n├── state/\n└── runtime/\n```\n\n`.env` and private state are preserved on repeated setup. Runtime assets are\nupdated from the npm package. Setup refuses a non-empty directory that is not a\nPortta installation.\n\n## First start\n\nThe default profile is `local`. After setup:\n\n```bash\n/opt/portta/bin/portta status\n/opt/portta/bin/portta doctor\n/opt/portta/bin/portta web up\n```\n\nThe panel starts on loopback by default. Configure authentication before using\na routed access mode:\n\n```bash\n/opt/portta/bin/portta config set panel.auth required\n/opt/portta/bin/portta web up --expose domain\n```\n\nOpen `/setup` once to create the owner, or use `portta auth bootstrap` from the\nhost. Project exposure remains a separate, explicit decision.\n\nSee the [installation reference](../reference/installation-reference.md),\n[authentication](../guides/authentication.md) and [configuration](../reference/configuration.md).\n","text":"Install Portta Install the gateway and panel from the published npm package. No repositorycheckout is needed. Requirements Node 22.12 or newer, including npm; Docker Engine 24 or newer with Compose v2; a POSIX host and shell. The installer does not install those prerequisites or request elevatedprivileges. Install The script hands control to npx portta setup. To install a specific publishedversion or choose a directory: Use --dry-run first when automating: Installation directory The directory contains the current release runtime and the host's state: .env and private state are preserved on repeated setup. Runtime assets areupdated from the npm package. Setup refuses a non-empty directory that is not aPortta installation. First start The default profile is local. After setup: The panel starts on loopback by default. Configure authentication before usinga routed access mode: Open /setup once to create the owner, or use portta auth bootstrap from thehost. Project exposure remains a separate, explicit decision. See the installation reference,authentication and configuration.","headings":[{"id":"install-portta","text":"Install Portta","level":1,"line":0},{"id":"requirements","text":"Requirements","level":2,"line":5},{"id":"install","text":"Install","level":2,"line":14},{"id":"installation-directory","text":"Installation directory","level":2,"line":35},{"id":"first-start","text":"First start","level":2,"line":56}],"kind":"markdown"},{"slug":"first-environment","title":"Configure your first environment","description":"Integrate and start your first Compose environment.","source":"docs/product/getting-started/first-environment.md","audience":"user","section":"Getting Started","category":"","url":"/docs/first-environment","markdown":"# Configure your first environment\n\nUse a local gateway and an existing Compose application you own. Install Portta first using [Install Portta](install.md).\n\n## Choose a namespace\n\nPortta derives a Compose namespace from the checkout and branch, and passes it\nwith `docker compose -p`. A worktree therefore receives its own containers,\nnetwork and named volumes without editing the application's `.env`.\n\n## Check the integration\n\n```bash\nportta prepare --dry-run\n```\n\nReview the Runtime Plan. HTTP services join the shared gateway network while\ndatabases remain on the application's private network. In auto mode, Portta\nremoves inherited host-port publications in its generated overlay; it does not\nmodify the application's Compose files.\n\nIf Portta cannot infer an HTTP service or its internal port, store only that\nintent outside Git:\n\n```bash\nportta init --service web:3000\n```\n\n## Start and verify\n\n```bash\nportta up\nportta status\nportta doctor\nportta urls\n```\n\nUse the URLs printed by Portta. A service named `web` under local hostname mode uses `<compose-project>-web.localhost`.\n\n## Next step\n\n[Add your first project](first-project.md) to understand the relationship between the running environment and its Project.\n","text":"Configure your first environment Use a local gateway and an existing Compose application you own. Install Portta first using Install Portta. Choose a namespace Portta derives a Compose namespace from the checkout and branch, and passes itwith docker compose -p. A worktree therefore receives its own containers,network and named volumes without editing the application's .env. Check the integration Review the Runtime Plan. HTTP services join the shared gateway network whiledatabases remain on the application's private network. In auto mode, Porttaremoves inherited host-port publications in its generated overlay; it does notmodify the application's Compose files. If Portta cannot infer an HTTP service or its internal port, store only thatintent outside Git: Start and verify Use the URLs printed by Portta. A service named web under local hostname mode uses <compose-project>-web.localhost. Next step Add your first project to understand the relationship between the running environment and its Project.","headings":[{"id":"configure-your-first-environment","text":"Configure your first environment","level":1,"line":0},{"id":"choose-a-namespace","text":"Choose a namespace","level":2,"line":4},{"id":"check-the-integration","text":"Check the integration","level":2,"line":10},{"id":"start-and-verify","text":"Start and verify","level":2,"line":28},{"id":"next-step","text":"Next step","level":2,"line":39}],"kind":"markdown"},{"slug":"first-project","title":"Add your first project","description":"Find your Project and its running environment in the panel.","source":"docs/product/getting-started/first-project.md","audience":"user","section":"Getting Started","category":"","url":"/docs/first-project","markdown":"# Add your first project\n\nA Project groups repositories and environments belonging to the same product. An Environment is one Compose namespace.\n\n## Prerequisites\n\nComplete [Configure your first environment](first-environment.md) and open the Portta panel.\n\n## Find the environment\n\n1. Open **Environments** and find `demo-shop-development`.\n2. Verify its working directory and services belong to your checkout.\n3. Open **Projects**, choose **New project**, enter `Demo shop` as the name and `demo-shop` as the slug, then choose **Create**. Use an account allowed to create Projects.\n4. Open the Project. The `--project demo-shop` integration label associates the environment with this existing Project; the label does not create the Project itself.\n5. Inspect its **Environments** and **Repositories**. If the environment has no label, use **Adopt an environment** to select it explicitly.\n\n## Expected result\n\nYour Project and running environment are visible without moving the repository or sharing another environment's state. If they are missing, use [Troubleshooting](../guides/troubleshooting.md) and verify the labels described in [Add an existing project](../guides/adopting-projects.md).\n\nSee [Manage projects](../guides/projects.md) for ongoing administration.\n","text":"Add your first project A Project groups repositories and environments belonging to the same product. An Environment is one Compose namespace. Prerequisites Complete Configure your first environment and open the Portta panel. Find the environment Open Environments and find demo-shop-development. Verify its working directory and services belong to your checkout. Open Projects, choose New project, enter Demo shop as the name and demo-shop as the slug, then choose Create. Use an account allowed to create Projects. Open the Project. The --project demo-shop integration label associates the environment with this existing Project; the label does not create the Project itself. Inspect its Environments and Repositories. If the environment has no label, use Adopt an environment to select it explicitly. Expected result Your Project and running environment are visible without moving the repository or sharing another environment's state. If they are missing, use Troubleshooting and verify the labels described in Add an existing project. See Manage projects for ongoing administration.","headings":[{"id":"add-your-first-project","text":"Add your first project","level":1,"line":0},{"id":"prerequisites","text":"Prerequisites","level":2,"line":4},{"id":"find-the-environment","text":"Find the environment","level":2,"line":8},{"id":"expected-result","text":"Expected result","level":2,"line":16}],"kind":"markdown"},{"slug":"web-ui","title":"Use the web panel","description":"Open the panel, navigate its pages and inspect live state.","source":"docs/product/guides/web-ui.md","audience":"user","section":"Guides","category":"Projects and environments","url":"/docs/web-ui","markdown":"# Use the web panel\n\nThe panel is where a development project is opened: what needs doing, who is\non it, which repositories make it up, what changed, which environments are\nrunning, how to reach and test them, what the logs say and how much of the\nhost they use. It complements the CLI and `portta mcp` rather than replacing\nthem: all three work on the same API and the same model\n([ADR 0032](../../development/adr/0032-portta-development-model.md)). Docker and Traefik remain\nthe live sources of runtime facts; the panel persists the decisions — Projects,\nrepositories, tasks, sessions — and a bounded history of what happened\n([ADR 0013](../../development/adr/0013-what-the-panel-persists.md)).\n\nIt is off by default.\n\n```bash\n./bin/portta web up\n./bin/portta web open # http://127.0.0.1:8081\n```\n\n![The Overview page: the work in progress, the active sessions, what needs attention, each project at a glance, recent code and the host's room](../../images/panel-overview.png)\n\nEvery screenshot on this page comes from the same host, described in\n`apps/web/e2e/demo-host.mjs`, seeded with deterministic UI fixtures, and rendered by\nthe real panel at 1440×900. Regenerate them with `npm run screenshots` (see\n[Development](../../development/development-setup.md#development-with-hot-reloading)).\n\n---\n\n## What it is for\n\nThe reference scenario is being away from the machine while an agent works on\na project on it. Open the panel: there is a task in progress, the agent that\ntook it, the repository it is in, the commits it produced, the branch and its\nstate, the environment running for it. Open the application through the\ndomain, the VPN or a protected address, test it, read the logs if something\nis off, add a note or a subtask, and the agent reads the context again and\ncarries on. The same flow, with a person instead of an agent, is a normal day.\n\nThe second scenario is a host with several projects and several agents on it:\nwhen it starts to run out of room, the Overview says which projects and\nenvironments are using it, and one of them can be stopped from there.\n\nIt is not a Docker management tool. There is no image management, no volume\nmanagement, no `docker compose` editor, no terminal, no prune, and no way to\ncreate an arbitrary container. See [Out of scope](../../development/panel-architecture.md#out-of-scope).\n\n\n## Starting it\n\n```bash\n./bin/portta web up # build if needed, then start\n./bin/portta web open # print the URL, and open a browser\n./bin/portta web status # where it listens, and whether it is healthy\n./bin/portta web logs # follow it\n./bin/portta web restart\n./bin/portta web down # stop it; the gateway keeps running\n./bin/portta web disable # stop it and take it out of `portta up`\n./bin/portta db status # database health\n./bin/portta db migrate # apply pending SQL without a restart\n```\n\n`web up` writes `PORTTA_WEB=true` to `.env`, so from then on\n`portta up` brings the panel along with the rest of the gateway.\n`web disable` undoes that.\n\nThe panel image still builds its own Node runtime. The host CLI requires Node\n22.12+.\n\n\n## Reaching it\n\n### Local\n\n`http://127.0.0.1:8081`, and nothing else. The port is published on\n`PORTTA_WEB_BIND_ADDRESS`, which is `127.0.0.1` and should stay that way.\n\nChange the port if 8081 is taken:\n\n```bash\n./bin/portta web up --port 8099\n```\n\n### Over the VPN\n\nOn a VPS, the panel is useful precisely when you are not sitting at the VPS.\nThe private profile routes it through Traefik, which on that profile listens on\nthe tailnet and nowhere else:\n\n```bash\n./bin/portta config set panel.auth required\n./bin/portta web up --expose vpn\n# https://portta-web.vpn.example.com\n```\n\nThis adds a Traefik router for `PORTTA_WEB_HOST.<domain>`. It is refused on the\n`remote-public` profile, where that private router would be public, and it is\nrefused while `PORTTA_AUTH_MODE` is `disabled`: a routed panel can stop and\nremove every container on the host, and it would answer anybody who found it.\n\nA routed panel also defaults to read-only. `--writable` opts out, deliberately.\n\n### Signing in\n\nThe panel signs people in itself. On a routed panel, `PORTTA_AUTH_MODE=required`\nmeans the first visit lands on `/setup`, which creates the owner — the only\naccount that is ever created that way. Everybody after that is created by an\nadministrator, and each of them has a role that decides what they may do.\n\n```bash\n# from the host, when there is no browser on it\nprintf %s \"$PASSWORD\" | ./bin/portta auth bootstrap \\\n --name 'Ada Lovelace' --email ada@example.com --password-stdin\n```\n\nThe session is a cookie the panel issues and can revoke; banning somebody takes\neffect on their next request. A CLI or a coding agent carries a `ptt_` token\ninstead, which never holds more than its owner's role. Nothing in front of the\npanel decides any of this. See [Authentication](authentication.md) and\n[ADR 0035](../../development/adr/0035-authentication-lives-in-the-panel.md).\n\n### Public exposure\n\n```bash\n./bin/portta web up --expose public\n# https://portta-web.dev.example.com\n```\n\nPublic exposure uses the same Portta login, lockout and host-scoped sessions.\nIt remains an explicit choice because this panel controls container lifecycle;\nprefer a VPN when the audience does not need a public path, and use TLS whenever\nthe route crosses an untrusted network.\n\nIf you are on a plain VPS without a VPN, an SSH tunnel is the answer:\n\n```bash\nssh -N -L 8081:127.0.0.1:8081 deploy@vps\n# then open http://127.0.0.1:8081 locally\n```\n\n### Read-only mode\n\n```bash\n./bin/portta web up --read-only\n```\n\nEvery mutating endpoint answers `403`. Useful when an agent is driving the\npanel and you want it to be able to look but not touch.\n\n---\n\n\n## Navigation\n\nUse [Projects](projects.md), [Environments](environments.md), [Services](services.md), [Tasks](tasks.md), and [Settings](panel-settings.md) for their operating procedures.\n\nSettings → Environment reads the host-written readiness report: installed\ntools, their versions and paths, and whether they are actually usable. The page\nshows the report age and asks for `portta env report` when a refresh is needed;\nit never probes the host from the panel container.\n\n### Overview\n\nThe Development Dashboard, in the order the questions come. The first is\nwhether this machine has room, so it is answered first, in the band at the\ntop; the rest follow:\n\n- **Work** — the tasks in progress, in review and blocked across every\n project, with the person or agent on each;\n- **Sessions** — who is working, on what, since when, with how many commits;\n- **Needs attention** — unhealthy services, degraded environments, tasks whose\n local edit conflicts with GitHub, a host under pressure, and what the\n gateway's own diagnostics failed;\n- **Projects** — each product at a glance: open and in-progress tasks, active\n sessions, running environments, health, last commit, last activity;\n- **Code** — the most recent commits across every repository, and the\n repositories with uncommitted or unpushed work;\n- **Using this host** — the environments using the most of it, each with a Stop.\n\nThe page has no visible title: its subject is the host, so the host is what\nit opens with. One line says what the machine is — its commercial name where\nthe platform reports one (`MacBook Pro`) or else its hostname, and whether it\nis a notebook, a desktop, a server or a virtual machine, from the chassis the\ncollector read; a machine that reports none of that gets its name and nothing\ninvented. The line under it holds the facts: the hostname when the commercial\nname took its place, the provider or hypervisor of a virtual machine\n(`Hetzner`, `QEMU`), the model, the OS and its version, the architecture, and\nhow long it has been up. Beside them sit the gateway's state, the host's\nverdict — **Normal**, **Watch**, **Under pressure** or **Critical**, computed\nfrom every reading together (see `hostPressure` in `packages/core`) — and the\nage of the last snapshot. Then every measurement `portta host collect`\nreported — CPU, memory, storage, and, where the machine has them, GPU,\ntemperature, battery and load — is one cell of a strip, with the last thirty\nminutes and the details in its tooltip. A host that has no battery grows no\nbattery cell. The top says who the machine is; the strip says how it is. The\nsame pressure is said once at each level: the verdict names it, the reading's\ncolour points at it, and the attention band spells out the readings that\ncaused it.\n\nThe page sizes itself to what there is to say. With nobody working, the\nsessions panel is a word in the work panel's corner rather than an empty\ncard; with nothing to act on, the attention band is one line; with no\ncommit collected yet, the code section is a heading and the command that\ncollects one.\n\nThe gateway's configuration lives on the Gateway page. Without PostgreSQL the\nwork and project sections are empty and say so; the runtime, the host and the\ndiagnostics still answer. It is served by `GET /api/overview`, which\n`portta overview` and an agent read too.\n\n\n### Tasks\n\n![The Demo Shop task board: backlog, to do, in progress and blocked, seeded from deterministic example fixtures](../../images/panel-tasks.png)\n\nA task is Portta's own: it exists without GitHub. `/projects/<slug>/tasks` is\nthe board — six columns, `Backlog`, `To do`, `In progress`, `Review`,\n`Blocked`, `Done` — or the list, nested by parent; the choice and the filters\n(status, assignee, repository, text) live in the hash, so a filtered view is\na link somebody can paste. A card moves by dragging or from its menu; the\nwrite happens at once and a refusal rolls it back visibly.\n\n![The Demo Shop tasks as a table: id, title, status, priority, type, assignee and when each last moved, with the sort and column controls](../../images/panel-tasks-table.png)\n\nThe **Table** view is the same rows as a table rather than as a board: sortable\nby any column, with the columns a given host does not care about switched off,\nand a status changed from the row without opening the task. Subtasks stay\nnested under their parent until a column is sorted on.\n\n![One task: the status control, the next step it offers, the description, its attachments, subtasks, sessions and activity](../../images/panel-task.png)\n\nA task page, `/projects/<slug>/tasks/<id>`, carries the description, the\nsubtasks, the notes, the sessions working on it and their commits, the\nenvironments it runs in (linked by the `portta.task` label, the branch name,\nthe namespace, or by hand) and the GitHub binding: which issue, whether the\nlast local edit reached GitHub (`synced`, `pending`, `conflict`), and the\nactions — bind to an existing issue, publish as a new one, sync, settle a\nconflict either way, see which shared fields differ, retry an errored sync,\nunbind, comment on the issue. See\n[GitHub synchronization](../concepts/github-sync.md#issues-and-tasks).\n\nIts activity section is one timeline: local comments, structured task changes,\nsession start/end rows, and the commits recorded by those sessions. Comments,\nchanges, and sessions/commits can be filtered without moving comments into a\nsecond store or inferring commit ownership from messages.\n\n`portta tasks` and the MCP tools read and write the same rows\n([CLI reference](../reference/cli.md), [MCP reference](../reference/mcp.md)).\n\n\n### Network\n\nDomains (local, VPN, public), TLS mode and ACME contact, Tailscale state, the\nDNS provider, every routed hostname with its target port, and the Docker\nnetworks with their role: shared, control, access, or a project's own.\n\n![The Network page: domains and TLS, the VPN and DNS settings, every routed hostname, and the Docker networks with their roles](../../images/panel-network.png)\n\n\n### Access\n\nDatabases, caches and anything else that speaks TCP rather than HTTP.\n\n```text\nPostgreSQL base-empresarial/postgres [ Open local access ]\n```\n\nand afterwards:\n\n```text\n127.0.0.1:55431 copy host copy port copy connection string close\n```\n\nThe bridge is the same one [`portta access open`](tcp-access.md) creates,\nwith byte-identical labels, so `portta access list`, `close` and `gc`\nmanage it too and neither tool is surprised by the other's work. It binds\n`127.0.0.1` on a port the kernel picks, so any number of databases can be\nreachable at once without one of them having to give up 5432.\n\nThe connection string is a template. It never contains a password: the gateway\ndoes not read a project's `.env` to be helpful.\n\nThe **Gateway address** column is the other way in, when\n[hostname routing](tcp-routing.md) is enabled: a stable\n`<project>-<service>.<domain>:<port>` that needs no bridge at all. Where a\nprotocol cannot do it the column says so rather than leaving a blank, and where\na project has not opted in it says that too.\n\n![The Access page: an open bridge to demo-shop/postgres on 127.0.0.1:55431 with its connection string, and the other TCP services each with an Open local access button](../../images/panel-access.png)\n\nThis page also lists persistent forwarders created with\n[`portta service publish --private`](tailscale-services.md).\n\n\n### Live updates\n\nTwo channels, and they carry different things.\n\n**The event stream** (`GET /api/events`, server-sent events) is what keeps the\npages current: a container changed state, a task moved, a repository was\nscanned. It needs `activity:read`, and every event is filtered against the\nprincipal it belongs to — an event about a Project somebody does not reach is\nnot delivered late or redacted, it is not delivered. Events with no Project in\nthem at all (a settings change, a gateway restart) go only to the people who\nsee everything. The browser reconnects on its own; the panel sends a keepalive\nevery twenty seconds so a proxy does not close a quiet stream.\n\n**The log stream** (`/ws/environments/:name/logs`) is a WebSocket, because\nfollowing a log is a stream and polling for it was three requests for the same\nlines every three seconds. Pressing **Follow** opens one connection and the\nlines arrive as Docker emits them. It reconnects with a widening delay, says so\nwhile it is trying, and falls back to the polling it replaced when it cannot\nstay up.\n\nThe handshake is authorised before it becomes a socket: `logs:read`, scoped to\nwhichever Project adopted the environment. A refusal is an HTTP status —\n`401` with no credential, `403` without the permission or the Project, `404`\nfor a path or an environment that is not there — and the socket is closed\nrather than left open. One listener handles every `/ws/…` path, so a path no\nroute claims is refused there rather than falling through to Next.\n\n\n### Light and dark\n\nThe theme is light, dark or system, chosen from the theme control at the foot\nof the sidebar or from the command menu. Only an explicit choice is stored, so\na panel that was never told keeps following the operating system. The same\nOverview, in the dark theme:\n\n![The Overview page in the dark theme](../../images/panel-overview-dark.png)\n\n---\n\n\n## Actions\n\n| Target | Available |\n|---|---|\n| Integrated service | logs, start, stop, restart, details, remove (with confirmation) |\n| External container | logs, start, stop, restart, details, remove (with confirmation) |\n| Project | restart its running services, open its URLs, see its services |\n| TCP service | open a loopback bridge, close it, copy host / port / connection string |\n| Gateway | status, diagnostics, logs, restart components, apply saved settings (opt-in) |\n\nNever offered: recreating **somebody else's** Compose project, editing\nconfiguration or environment variables of a container, changing its networks or\nvolumes, running an arbitrary command, `docker compose down -v`, resetting a\ndatabase, mass removal, or any kind of prune.\n\nThe one exception is the gateway's own project, and only through the opt-in\napplier described below ([ADR 0026](../../development/adr/0026-applying-settings-from-the-panel.md)):\na container the host prepares, whose command is fixed at creation and which the\npanel can only start.\n\n### Removing a container\n\nThe only destructive action, and it always asks first. The confirmation names\nthe container and its image, says whether it belongs to the gateway or is\nexternal, and lists its named volumes and bind mounts.\n\nWhat a removal does **not** do:\n\n- it does not remove a volume, named or anonymous (the call is always\n `v=0&link=0`);\n- it does not remove a network;\n- it does not remove an image;\n- it does not touch a sibling in the same Compose project;\n- it never runs a prune.\n\nGateway components cannot be removed from the panel at all. Access bridges are\nclosed from the Access page, which removes them cleanly.\n\n### Restarting the gateway\n\n`Restart Traefik` restarts the container in place. Traefik reads its static\nconfiguration from the environment it was created with\n([ADR 0003](../../development/adr/0003-traefik-static-config-via-env.md)), so a settings change\nneeds the containers **recreated**, not restarted. The panel says this rather\nthan pretending a restart was enough: saved settings the running gateway has not\npicked up are marked `pending restart`, and a bar at the top of every page says\nso wherever you are.\n\nBy default, applying them is a command on the host:\n\n```bash\n./bin/portta up local\n```\n\n### Applying settings from the panel\n\nWith `PORTTA_APPLY=true` in `.env`, `portta up` also prepares a stopped\ncontainer whose command is fixed at creation — `portta up`, with no argument the\npanel can influence — and the pending bar gains an **Apply and restart** button\nthat starts it.\n\nThe confirmation names the pending keys, and says plainly that this panel is one\nof the containers being recreated. It then shows a dialog with a stopwatch while\nthe panel goes offline and comes back, and reports the applier's exit code and\noutput if it failed. If a pending setting moves the panel's own address, the\nconfirmation says the tab will not reconnect on its own.\n\nOn a repository checkout the apply rebuilds the local images first, which takes\nminutes rather than seconds on a cold cache. The confirmation says so, and the\npanel waits longer before declaring a timeout. If there is no applier at all,\nthe bar names which of the three reasons applies — the key is off, this host\nrefuses, or `portta up` has not prepared one yet — rather than guessing.\n\nTurning this on is a host decision, deliberately: the key is not in the panel's\nfield catalogue, so the panel cannot enable itself. Be clear about what it\ngrants — anyone who can write through the panel can then run `portta up` on the\nhost. It is refused in read-only mode, refused when the panel is exposed\npublicly, and refused on the `remote-public` profile. See\n[ADR 0026](../../development/adr/0026-applying-settings-from-the-panel.md) for the full account,\nincluding what can still go wrong.\n\n---\n\n\n## Configuration\n\nAll of these live in `.env`; `portta web up` sets the first ones for you.\n\n| Key | Default | Meaning |\n|---|---|---|\n| `PORTTA_WEB` | `false` | Whether the panel starts with the gateway |\n| `PORTTA_WEB_BIND_ADDRESS` | `127.0.0.1` | Interface the panel is published on |\n| `PORTTA_WEB_PORT` | `8081` | Host port |\n| `PORTTA_WEB_EXPOSE` | `local` | `local`, or `vpn` to add a Traefik router |\n| `PORTTA_WEB_HOST` | `portta-web` | Hostname label used by `--expose vpn` |\n| `PORTTA_WEB_READ_ONLY` | `false` | Refuse every mutating endpoint |\n| `PORTTA_WEB_DEV` | `false` | Development mode: HMR on the same port the API answers on |\n| `PORTTA_WEB_NETWORK` | `portta-web` | The panel's internal control network |\n| `PORTTA_WEB_USER` | `node` | User the container runs as, see below |\n\n`.env` is owner-only, so the container has to run as whoever owns it. The\ninstaller records this, and `bootstrap` and `web up` now record it too when the\nkey is absent:\n\n```bash\nPORTTA_WEB_USER=1000:1000 # $(id -u):$(id -g)\n```\n\nThe image's own `node` is a last resort, and is right only when the host uid\nhappens to be 1000 — on macOS it is usually 501, so the default was wrong there\nas well, not only on Linux. The panel reports whether the file is writable and\nsays to edit it on the host when it is not.\n\nIn development mode the container keeps running as `node` on purpose: it writes\nno host file, and it does write inside the image, where only `node` has\npermission.\n\n---\n\n\n## Security\n\nThe panel is the one component that can start, stop and remove containers, so\nwhat it cannot do matters more than what it can.\n\n**Network.** Loopback by default. VPN routing, the dedicated public panel\nentrypoint and routing on the gateway's own domain are separate, explicit\noverlays and all three are refused without a credential. Public panel exposure\ndoes not publish the application's `web`/`websecure` entrypoints.\n\n`PORTTA_WEB_EXPOSE=domain` routes the panel at one hostname of the gateway's\ndomain, on `websecure`, so it gets the certificate that entrypoint already\nterminates instead of the plain HTTP the `panel` entrypoint serves. It requires\nTLS and a credential, publishes no host port, and names exactly one host — an\napplication is still reachable only through a router of its own. What it gives\nup, and why, is written down in\n[ADR 0021](../../development/adr/0021-panel-access-modes.md).\n\n**Authentication.** Traefik calls the separate `portta-auth` process before\nforwarding a protected request. The password is generated, shown once and\nstored only as scrypt in `state/auth/protections.json`; the auth process mounts\nthat file read-only and has no Docker socket or database. A middleware Traefik\ncannot resolve makes the router fail closed. `doctor` and the panel's own\ndiagnostics fail, not warn, when the secret, store or auth service is missing or\nunsafe. See [Authentication](authentication.md).\n\n**Traefik configuration.** The panel mounts `config/traefik/dynamic/`\nread-write and may write exactly three filenames in it: `portta-shares.yaml`,\n`portta-aliases.yaml` and `portta-auth.yaml`. Any other path is refused in its own process,\nbefore the write. Everything else in that directory\nis yours. See [ADR 0011](../../development/adr/0011-bounded-traefik-write-surface.md).\n\n**Docker.** Its socket proxy grants the read endpoints plus the container\nlifecycle, and denies images, volumes, exec, build, swarm, secrets, plugins and\nthe system endpoints. On top of that the panel refuses to emit any request that\nis not on its own allowlist, so `prune`, `exec`, `archive` and `attach` are\ndenied even where the proxy would forward them. See\n[ADR 0008](../../development/adr/0008-web-panel-socket-proxy.md).\n\n**Container creation.** One shape only: the socat TCP bridge, with a fixed\nimage, fixed labels, no binds, no mounts, no capabilities and no privileged\nmode. There is no generic create endpoint.\n\n**Secrets.** `TS_AUTHKEY` and `CF_DNS_API_TOKEN` are never returned by the API,\nin whole or in part. The panel reports only whether they are set. Sending an\nempty string leaves a secret unchanged; clearing one is explicit. `.env` is\nwritten through a temporary file with mode `600`.\n\n**Writes from another site.** A page on another origin can point a request at\n`127.0.0.1`. Reads behind loopback are harmless enough; writes are not, so a\nmutating request must come from the panel's own origin (or `localhost`).\n\n**Input.** Every request body is validated with a schema before anything acts\non it. Container ids are checked against Docker's own shape. No shell command\nis ever built from a value the UI supplied, because the panel runs no shell\ncommands at all.\n\n`tests/unit/web.test.sh` asserts each of these as an invariant, so loosening one\nfails the build. The wider threat model is in [Security](../concepts/security.md).\n\n---\n\n\n## Troubleshooting\n\n**The panel does not come up.**\n\n```bash\n./bin/portta web status\n./bin/portta web logs\n```\n\n**\"cannot reach the Docker socket proxy\".** The panel's proxy is not running or\nnot healthy:\n\n```bash\n./bin/portta web logs web-socket-proxy\n./bin/portta web restart\n```\n\n**Everything is empty, and the Overview says the Docker API is unreachable.**\nThe proxy is up but denying calls. Confirm the panel is talking to its own\nproxy (`PORTTA_RUNTIME_DOCKER_API`), not Traefik's read-only one, which denies every\nwrite.\n\n**\"Open local access\" says the bridge image is not on this host.** The panel\ncannot pull images, deliberately. Pull it once on the host:\n\n```bash\ndocker pull alpine/socat:1.8.1.3\n```\n\n`portta web up` does this for you; this happens when the panel was started\nsome other way.\n\n**Settings will not save.** The panel reports the file as not writable. On\nLinux, set `PORTTA_WEB_USER` as above, or edit `.env` on the host.\n\n**A saved setting has no effect.** Traefik reads its static configuration at\nstartup. Run `./bin/portta up <profile>` on the host; the panel shows the\nexact command.\n\n**The live indicator says `offline`.** The event stream dropped. The panel\nreconnects on its own, with backoff; a reload also does it. Everything else\nkeeps working, it just stops updating by itself.\n\n**Port 8081 is taken.** `./bin/portta web up --port 8099`. The Docker page\nshows which container is holding it.\n\n**A container I removed came back.** It belonged to a Compose project, and\nsomething ran `docker compose up` in that project's directory. The panel warns\nabout this in the confirmation.\n\n---\n","text":"Use the web panel The panel is where a development project is opened: what needs doing, who ison it, which repositories make it up, what changed, which environments arerunning, how to reach and test them, what the logs say and how much of thehost they use. It complements the CLI and portta mcp rather than replacingthem: all three work on the same API and the same model(ADR 0032). Docker and Traefik remainthe live sources of runtime facts; the panel persists the decisions — Projects,repositories, tasks, sessions — and a bounded history of what happened(ADR 0013). It is off by default. The Overview page: the work in progress, the active sessions, what needs attention, each project at a glance, recent code and the host's room Every screenshot on this page comes from the same host, described inapps/web/e2e/demo-host.mjs, seeded with deterministic UI fixtures, and rendered bythe real panel at 1440×900. Regenerate them with npm run screenshots (seeDevelopment). What it is for The reference scenario is being away from the machine while an agent works ona project on it. Open the panel: there is a task in progress, the agent thattook it, the repository it is in, the commits it produced, the branch and itsstate, the environment running for it. Open the application through thedomain, the VPN or a protected address, test it, read the logs if somethingis off, add a note or a subtask, and the agent reads the context again andcarries on. The same flow, with a person instead of an agent, is a normal day. The second scenario is a host with several projects and several agents on it:when it starts to run out of room, the Overview says which projects andenvironments are using it, and one of them can be stopped from there. It is not a Docker management tool. There is no image management, no volumemanagement, no docker compose editor, no terminal, no prune, and no way tocreate an arbitrary container. See Out of scope. Starting it web up writes PORTTA_WEB=true to .env, so from then onportta up brings the panel along with the rest of the gateway.web disable undoes that. The panel image still builds its own Node runtime. The host CLI requires Node22.12+. Reaching it Local http://127.0.0.1:8081, and nothing else. The port is published onPORTTA_WEB_BIND_ADDRESS, which is 127.0.0.1 and should stay that way. Change the port if 8081 is taken: Over the VPN On a VPS, the panel is useful precisely when you are not sitting at the VPS.The private profile routes it through Traefik, which on that profile listens onthe tailnet and nowhere else: This adds a Traefik router for PORTTA_WEB_HOST.<domain>. It is refused on theremote-public profile, where that private router would be public, and it isrefused while PORTTA_AUTH_MODE is disabled: a routed panel can stop andremove every container on the host, and it would answer anybody who found it. A routed panel also defaults to read-only. --writable opts out, deliberately. Signing in The panel signs people in itself. On a routed panel, PORTTA_AUTH_MODE=requiredmeans the first visit lands on /setup, which creates the owner — the onlyaccount that is ever created that way. Everybody after that is created by anadministrator, and each of them has a role that decides what they may do. The session is a cookie the panel issues and can revoke; banning somebody takeseffect on their next request. A CLI or a coding agent carries a ptt_ tokeninstead, which never holds more than its owner's role. Nothing in front of thepanel decides any of this. See Authentication andADR 0035. Public exposure Public exposure uses the same Portta login, lockout and host-scoped sessions.It remains an explicit choice because this panel controls container lifecycle;prefer a VPN when the audience does not need a public path, and use TLS wheneverthe route crosses an untrusted network. If you are on a plain VPS without a VPN, an SSH tunnel is the answer: Read-only mode Every mutating endpoint answers 403. Useful when an agent is driving thepanel and you want it to be able to look but not touch. Navigation Use Projects, Environments, Services, Tasks, and Settings for their operating procedures. Settings → Environment reads the host-written readiness report: installedtools, their versions and paths, and whether they are actually usable. The pageshows the report age and asks for portta env report when a refresh is needed;it never probes the host from the panel container. Overview The Development Dashboard, in the order the questions come. The first iswhether this machine has room, so it is answered first, in the band at thetop; the rest follow: Work — the tasks in progress, in review and blocked across everyproject, with the person or agent on each; Sessions — who is working, on what, since when, with how many commits; Needs attention — unhealthy services, degraded environments, tasks whoselocal edit conflicts with GitHub, a host under pressure, and what thegateway's own diagnostics failed; Projects — each product at a glance: open and in-progress tasks, activesessions, running environments, health, last commit, last activity; Code — the most recent commits across every repository, and therepositories with uncommitted or unpushed work; Using this host — the environments using the most of it, each with a Stop. The page has no visible title: its subject is the host, so the host is whatit opens with. One line says what the machine is — its commercial name wherethe platform reports one (MacBook Pro) or else its hostname, and whether itis a notebook, a desktop, a server or a virtual machine, from the chassis thecollector read; a machine that reports none of that gets its name and nothinginvented. The line under it holds the facts: the hostname when the commercialname took its place, the provider or hypervisor of a virtual machine(Hetzner, QEMU), the model, the OS and its version, the architecture, andhow long it has been up. Beside them sit the gateway's state, the host'sverdict — Normal, Watch, Under pressure or Critical, computedfrom every reading together (see hostPressure in packages/core) — and theage of the last snapshot. Then every measurement portta host collectreported — CPU, memory, storage, and, where the machine has them, GPU,temperature, battery and load — is one cell of a strip, with the last thirtyminutes and the details in its tooltip. A host that has no battery grows nobattery cell. The top says who the machine is; the strip says how it is. Thesame pressure is said once at each level: the verdict names it, the reading'scolour points at it, and the attention band spells out the readings thatcaused it. The page sizes itself to what there is to say. With nobody working, thesessions panel is a word in the work panel's corner rather than an emptycard; with nothing to act on, the attention band is one line; with nocommit collected yet, the code section is a heading and the command thatcollects one. The gateway's configuration lives on the Gateway page. Without PostgreSQL thework and project sections are empty and say so; the runtime, the host and thediagnostics still answer. It is served by GET /api/overview, whichportta overview and an agent read too. Tasks The Demo Shop task board: backlog, to do, in progress and blocked, seeded from deterministic example fixtures A task is Portta's own: it exists without GitHub. /projects/<slug>/tasks isthe board — six columns, Backlog, To do, In progress, Review,Blocked, Done — or the list, nested by parent; the choice and the filters(status, assignee, repository, text) live in the hash, so a filtered view isa link somebody can paste. A card moves by dragging or from its menu; thewrite happens at once and a refusal rolls it back visibly. The Demo Shop tasks as a table: id, title, status, priority, type, assignee and when each last moved, with the sort and column controls The Table view is the same rows as a table rather than as a board: sortableby any column, with the columns a given host does not care about switched off,and a status changed from the row without opening the task. Subtasks staynested under their parent until a column is sorted on. One task: the status control, the next step it offers, the description, its attachments, subtasks, sessions and activity A task page, /projects/<slug>/tasks/<id>, carries the description, thesubtasks, the notes, the sessions working on it and their commits, theenvironments it runs in (linked by the portta.task label, the branch name,the namespace, or by hand) and the GitHub binding: which issue, whether thelast local edit reached GitHub (synced, pending, conflict), and theactions — bind to an existing issue, publish as a new one, sync, settle aconflict either way, see which shared fields differ, retry an errored sync,unbind, comment on the issue. SeeGitHub synchronization. Its activity section is one timeline: local comments, structured task changes,session start/end rows, and the commits recorded by those sessions. Comments,changes, and sessions/commits can be filtered without moving comments into asecond store or inferring commit ownership from messages. portta tasks and the MCP tools read and write the same rows(CLI reference, MCP reference). Network Domains (local, VPN, public), TLS mode and ACME contact, Tailscale state, theDNS provider, every routed hostname with its target port, and the Dockernetworks with their role: shared, control, access, or a project's own. The Network page: domains and TLS, the VPN and DNS settings, every routed hostname, and the Docker networks with their roles Access Databases, caches and anything else that speaks TCP rather than HTTP. and afterwards: The bridge is the same one portta access open creates,with byte-identical labels, so portta access list, close and gcmanage it too and neither tool is surprised by the other's work. It binds127.0.0.1 on a port the kernel picks, so any number of databases can bereachable at once without one of them having to give up 5432. The connection string is a template. It never contains a password: the gatewaydoes not read a project's .env to be helpful. The Gateway address column is the other way in, whenhostname routing is enabled: a stable<project>-<service>.<domain>:<port> that needs no bridge at all. Where aprotocol cannot do it the column says so rather than leaving a blank, and wherea project has not opted in it says that too. The Access page: an open bridge to demo-shop/postgres on 127.0.0.1:55431 with its connection string, and the other TCP services each with an Open local access button This page also lists persistent forwarders created withportta service publish --private. Live updates Two channels, and they carry different things. The event stream (GET /api/events, server-sent events) is what keeps thepages current: a container changed state, a task moved, a repository wasscanned. It needs activity:read, and every event is filtered against theprincipal it belongs to — an event about a Project somebody does not reach isnot delivered late or redacted, it is not delivered. Events with no Project inthem at all (a settings change, a gateway restart) go only to the people whosee everything. The browser reconnects on its own; the panel sends a keepaliveevery twenty seconds so a proxy does not close a quiet stream. The log stream (/ws/environments/:name/logs) is a WebSocket, becausefollowing a log is a stream and polling for it was three requests for the samelines every three seconds. Pressing Follow opens one connection and thelines arrive as Docker emits them. It reconnects with a widening delay, says sowhile it is trying, and falls back to the polling it replaced when it cannotstay up. The handshake is authorised before it becomes a socket: logs:read, scoped towhichever Project adopted the environment. A refusal is an HTTP status —401 with no credential, 403 without the permission or the Project, 404for a path or an environment that is not there — and the socket is closedrather than left open. One listener handles every /ws/… path, so a path noroute claims is refused there rather than falling through to Next. Light and dark The theme is light, dark or system, chosen from the theme control at the footof the sidebar or from the command menu. Only an explicit choice is stored, soa panel that was never told keeps following the operating system. The sameOverview, in the dark theme: The Overview page in the dark theme Actions Target Available Integrated service logs, start, stop, restart, details, remove (with confirmation) External container logs, start, stop, restart, details, remove (with confirmation) Project restart its running services, open its URLs, see its services TCP service open a loopback bridge, close it, copy host / port / connection string Gateway status, diagnostics, logs, restart components, apply saved settings (opt-in) Never offered: recreating somebody else's Compose project, editingconfiguration or environment variables of a container, changing its networks orvolumes, running an arbitrary command, docker compose down -v, resetting adatabase, mass removal, or any kind of prune. The one exception is the gateway's own project, and only through the opt-inapplier described below (ADR 0026):a container the host prepares, whose command is fixed at creation and which thepanel can only start. Removing a container The only destructive action, and it always asks first. The confirmation namesthe container and its image, says whether it belongs to the gateway or isexternal, and lists its named volumes and bind mounts. What a removal does not do: it does not remove a volume, named or anonymous (the call is alwaysv=0&link=0); it does not remove a network; it does not remove an image; it does not touch a sibling in the same Compose project; it never runs a prune. Gateway components cannot be removed from the panel at all. Access bridges areclosed from the Access page, which removes them cleanly. Restarting the gateway Restart Traefik restarts the container in place. Traefik reads its staticconfiguration from the environment it was created with(ADR 0003), so a settings changeneeds the containers recreated, not restarted. The panel says this ratherthan pretending a restart was enough: saved settings the running gateway has notpicked up are marked pending restart, and a bar at the top of every page saysso wherever you are. By default, applying them is a command on the host: Applying settings from the panel With PORTTA_APPLY=true in .env, portta up also prepares a stoppedcontainer whose command is fixed at creation — portta up, with no argument thepanel can influence — and the pending bar gains an Apply and restart buttonthat starts it. The confirmation names the pending keys, and says plainly that this panel is oneof the containers being recreated. It then shows a dialog with a stopwatch whilethe panel goes offline and comes back, and reports the applier's exit code andoutput if it failed. If a pending setting moves the panel's own address, theconfirmation says the tab will not reconnect on its own. On a repository checkout the apply rebuilds the local images first, which takesminutes rather than seconds on a cold cache. The confirmation says so, and thepanel waits longer before declaring a timeout. If there is no applier at all,the bar names which of the three reasons applies — the key is off, this hostrefuses, or portta up has not prepared one yet — rather than guessing. Turning this on is a host decision, deliberately: the key is not in the panel'sfield catalogue, so the panel cannot enable itself. Be clear about what itgrants — anyone who can write through the panel can then run portta up on thehost. It is refused in read-only mode, refused when the panel is exposedpublicly, and refused on the remote-public profile. SeeADR 0026 for the full account,including what can still go wrong. Configuration All of these live in .env; portta web up sets the first ones for you. Key Default Meaning PORTTA_WEB false Whether the panel starts with the gateway PORTTA_WEB_BIND_ADDRESS 127.0.0.1 Interface the panel is published on PORTTA_WEB_PORT 8081 Host port PORTTA_WEB_EXPOSE local local, or vpn to add a Traefik router PORTTA_WEB_HOST portta-web Hostname label used by --expose vpn PORTTA_WEB_READ_ONLY false Refuse every mutating endpoint PORTTA_WEB_DEV false Development mode: HMR on the same port the API answers on PORTTA_WEB_NETWORK portta-web The panel's internal control network PORTTA_WEB_USER node User the container runs as, see below .env is owner-only, so the container has to run as whoever owns it. Theinstaller records this, and bootstrap and web up now record it too when thekey is absent: The image's own node is a last resort, and is right only when the host uidhappens to be 1000 — on macOS it is usually 501, so the default was wrong thereas well, not only on Linux. The panel reports whether the file is writable andsays to edit it on the host when it is not. In development mode the container keeps running as node on purpose: it writesno host file, and it does write inside the image, where only node haspermission. Security The panel is the one component that can start, stop and remove containers, sowhat it cannot do matters more than what it can. Network. Loopback by default. VPN routing, the dedicated public panelentrypoint and routing on the gateway's own domain are separate, explicitoverlays and all three are refused without a credential. Public panel exposuredoes not publish the application's web/websecure entrypoints. PORTTA_WEB_EXPOSE=domain routes the panel at one hostname of the gateway'sdomain, on websecure, so it gets the certificate that entrypoint alreadyterminates instead of the plain HTTP the panel entrypoint serves. It requiresTLS and a credential, publishes no host port, and names exactly one host — anapplication is still reachable only through a router of its own. What it givesup, and why, is written down inADR 0021. Authentication. Traefik calls the separate portta-auth process beforeforwarding a protected request. The password is generated, shown once andstored only as scrypt in state/auth/protections.json; the auth process mountsthat file read-only and has no Docker socket or database. A middleware Traefikcannot resolve makes the router fail closed. doctor and the panel's owndiagnostics fail, not warn, when the secret, store or auth service is missing orunsafe. See Authentication. Traefik configuration. The panel mounts config/traefik/dynamic/read-write and may write exactly three filenames in it: portta-shares.yaml,portta-aliases.yaml and portta-auth.yaml. Any other path is refused in its own process,before the write. Everything else in that directoryis yours. See ADR 0011. Docker. Its socket proxy grants the read endpoints plus the containerlifecycle, and denies images, volumes, exec, build, swarm, secrets, plugins andthe system endpoints. On top of that the panel refuses to emit any request thatis not on its own allowlist, so prune, exec, archive and attach aredenied even where the proxy would forward them. SeeADR 0008. Container creation. One shape only: the socat TCP bridge, with a fixedimage, fixed labels, no binds, no mounts, no capabilities and no privilegedmode. There is no generic create endpoint. Secrets. TS_AUTHKEY and CF_DNS_API_TOKEN are never returned by the API,in whole or in part. The panel reports only whether they are set. Sending anempty string leaves a secret unchanged; clearing one is explicit. .env iswritten through a temporary file with mode 600. Writes from another site. A page on another origin can point a request at127.0.0.1. Reads behind loopback are harmless enough; writes are not, so amutating request must come from the panel's own origin (or localhost). Input. Every request body is validated with a schema before anything actson it. Container ids are checked against Docker's own shape. No shell commandis ever built from a value the UI supplied, because the panel runs no shellcommands at all. tests/unit/web.test.sh asserts each of these as an invariant, so loosening onefails the build. The wider threat model is in Security. Troubleshooting The panel does not come up. \"cannot reach the Docker socket proxy\". The panel's proxy is not running ornot healthy: Everything is empty, and the Overview says the Docker API is unreachable.The proxy is up but denying calls. Confirm the panel is talking to its ownproxy (PORTTA_RUNTIME_DOCKER_API), not Traefik's read-only one, which denies everywrite. \"Open local access\" says the bridge image is not on this host. The panelcannot pull images, deliberately. Pull it once on the host: portta web up does this for you; this happens when the panel was startedsome other way. Settings will not save. The panel reports the file as not writable. OnLinux, set PORTTA_WEB_USER as above, or edit .env on the host. A saved setting has no effect. Traefik reads its static configuration atstartup. Run ./bin/portta up <profile> on the host; the panel shows theexact command. The live indicator says offline. The event stream dropped. The panelreconnects on its own, with backoff; a reload also does it. Everything elsekeeps working, it just stops updating by itself. Port 8081 is taken. ./bin/portta web up --port 8099. The Docker pageshows which container is holding it. A container I removed came back. It belonged to a Compose project, andsomething ran docker compose up in that project's directory. The panel warnsabout this in the confirmation.","headings":[{"id":"use-the-web-panel","text":"Use the web panel","level":1,"line":0},{"id":"what-it-is-for","text":"What it is for","level":2,"line":28},{"id":"starting-it","text":"Starting it","level":2,"line":47},{"id":"reaching-it","text":"Reaching it","level":2,"line":69},{"id":"local","text":"Local","level":3,"line":71},{"id":"over-the-vpn","text":"Over the VPN","level":3,"line":82},{"id":"signing-in","text":"Signing in","level":3,"line":101},{"id":"public-exposure","text":"Public exposure","level":3,"line":120},{"id":"read-only-mode","text":"Read-only mode","level":3,"line":139},{"id":"navigation","text":"Navigation","level":2,"line":151},{"id":"overview","text":"Overview","level":3,"line":160},{"id":"tasks","text":"Tasks","level":3,"line":210},{"id":"network","text":"Network","level":3,"line":249},{"id":"access","text":"Access","level":3,"line":258},{"id":"live-updates","text":"Live updates","level":3,"line":293},{"id":"light-and-dark","text":"Light and dark","level":3,"line":321},{"id":"actions","text":"Actions","level":2,"line":333},{"id":"removing-a-container","text":"Removing a container","level":3,"line":353},{"id":"restarting-the-gateway","text":"Restarting the gateway","level":3,"line":371},{"id":"applying-settings-from-the-panel","text":"Applying settings from the panel","level":3,"line":387},{"id":"configuration","text":"Configuration","level":2,"line":417},{"id":"security","text":"Security","level":2,"line":453},{"id":"troubleshooting","text":"Troubleshooting","level":2,"line":516}],"kind":"markdown"},{"slug":"adopting-projects","title":"Add an existing project","description":"Add a Portta overlay to an existing Compose application.","source":"docs/product/guides/adopting-projects.md","audience":"user","section":"Guides","category":"Projects and environments","url":"/docs/adopting-projects","markdown":"# Add an existing project\n\nYour project stays where it is, in its own repository, started from its own\ndirectory. Adoption does not modify it: Portta keeps its generated runtime\noverlay under its own installation state.\n\n## Start here\n\n```bash\nportta env analyze /path/to/project\n```\n\nIt reads the project and reports what adoption would take: every service and\nwhat it looks like, the host ports it publishes and what already holds them,\nfixed container names and whether the host already holds them, datastores that\nare published, whether the namespace is implicit, and whether the namespace is\nalready in use by another checkout. It writes nothing. When the Compose file is\nnot `compose.yaml` in that directory, name it with `--file deploy/compose.yaml`\n(relative to the path, or absolute): the file's directory becomes the project\ndirectory.\n\nThen adopt it through the canonical workflow:\n\n```bash\nportta adopt /path/to/project --dry-run --json\nportta adopt /path/to/project\nportta runtime up /path/to/project\n```\n\n`adopt` records an audited Runtime Plan outside the source repository. It\nresolves the complete ordered Compose input set, validates it once before and\nonce after rendering the overlay, and derives a unique Compose project name\nfrom the workspace and current branch. `runtime up`, `runtime down`, `runtime status`, `runtime logs` and\n`runtime config` reuse exactly that plan.\n\n```bash\nportta adopt /path/to/project --dry-run --json # see the plan and pending choices\nportta adopt /path/to/project --service web:3000\nportta runtime status /path/to/project\n```\n\n`adopt` persists optional exposure intent in Portta state. A project that wants\nto version this intent may instead provide `.portta/runtime.json`; it contains\nonly files, profiles, manual/auto mode and service ports — never gateway\ndomains, networks, labels, URLs or a concrete Compose namespace.\n\n`prepare` and `init` remain available for scripts that already use them, but\nnew integrations should use `adopt`: it is the one closed interface for\ninspection, explicit decisions and plan materialization.\n\n## Decisions that need confirmation\n\nThe dry run returns `outcome: \"isolated\"`, `\"manual\"`, or `\"not_supported\"`.\nIt also lists every unresolved choice under `pending`; no Compose or Portta\nruntime file is written in this mode. A project with no HTTP surface is still\nan isolated private environment. A multi-port service stays private until a\nspecific HTTP surface is named with `--service name:port`.\n\nFixed resource names need an explicit acknowledgement because they can cross\nthe Compose namespace boundary:\n\n```bash\n# The fixed name is reset only for this service and only after this declaration.\nportta adopt /path/to/project --remove-container-name web\n\n# These retain the source resource names; use only when sharing is intentional.\nportta adopt /path/to/project --allow-shared-networks --allow-shared-volumes\n\n# A project-owned integration that Portta must not modify.\nportta adopt /path/to/project --manual\n```\n\n`network_mode` on a routed service is not supported, because Compose cannot\nalso attach it to the Portta network. `container_name` is never reset silently.\nBind mounts and privileged settings remain visible findings: they are not\nrewritten by Portta and should be reviewed against the host's security policy.\n\n## The contract\n\nA compatible project:\n\n1. uses Docker Compose 2.24.4 or newer;\n2. keeps its own private network and normal internal ports;\n3. avoids `container_name:` for isolated environments;\n4. does not route services that use `network_mode`;\n5. lets Portta remove inherited host `ports:` in auto mode.\n\nNothing else. No Dockerfile changes, no directory moves, no shared base image.\n\n## Optional Portta login\n\nThe project owns its router labels, so Portta never adds authentication behind\nyour back. To protect a hostname, create its credential and opt the router into\nthe generated middleware:\n\n```bash\nportta protect host demo-web.example.com --project demo --service web\n```\n\n```yaml\nlabels:\n - \"traefik.http.routers.demo-web.middlewares=portta-forward-auth@file\"\n```\n\n`/__portta/auth` is reserved on protected hosts for the login and logout routes.\nSee [Authentication](authentication.md) for rotation, removal and API clients.\n\n## The overlay\n\nPortta derives its runtime overlay outside the repository, so `compose.yaml`\nstill describes the application and the project still runs standalone without\nthe gateway:\n\n```yaml\n# generated by Portta, outside the project repository\nservices:\n web:\n ports: !reset []\n networks:\n - default # keep reaching postgres/redis privately\n - portta # accept traffic from the gateway\n labels:\n - \"traefik.enable=true\"\n - \"traefik.docker.network=portta\"\n - \"traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=3000\"\n\n api:\n ports: !reset []\n networks:\n - default\n - portta\n labels:\n - \"traefik.enable=true\"\n - \"traefik.docker.network=portta\"\n - \"traefik.http.services.${COMPOSE_PROJECT_NAME}-api.loadbalancer.server.port=8000\"\n\nnetworks:\n portta:\n external: true\n name: portta\n```\n\n```bash\ndocker compose -f compose.yaml -f /path/to/portta/runtime/compose.portta.yaml up -d\n```\n\nThe Portta runtime wrapper supplies these files and `-p <namespace>`; do not\nadd its overlay to the project's `.env` or `COMPOSE_FILE`.\n\nWhen Compose reports only plain network names, Portta uses `!override` to make\nthe final routed-network set explicit. If the source has aliases or other\nnetwork metadata, it uses Compose's normal merge so that metadata is preserved.\n\nWorking examples are independent repositories:\n[`portta-demo-a`](https://github.com/fabioassuncao/portta-demo-a) and\n[`portta-demo-b`](https://github.com/fabioassuncao/portta-demo-b) for the CI pair;\nalso [`portta-demo-site`](https://github.com/fabioassuncao/portta-demo-site)\n(single web), [`portta-demo-shop`](https://github.com/fabioassuncao/portta-demo-shop)\n(full stack with MySQL, Mailpit and RustFS),\n[`portta-demo-monorepo`](https://github.com/fabioassuncao/portta-demo-monorepo), and\n[`portta-demo-external`](https://github.com/fabioassuncao/portta-demo-external) (never adopted, for the panel's\nExternal Docker section). Templates for the usual project shapes are in\n[`templates/`](../../../templates/README.md).\n\n## Two rules that are easy to get wrong\n\n**Generated labels use list form.** Compose interpolates `${VAR}` inside a list entry\nbut **not** inside a mapping key. In map form the Traefik service name stays the\nliteral `${COMPOSE_PROJECT_NAME}` and every worktree of the project collapses\nonto one load balancer.\n\n**Prefix Traefik service names with the namespace.** Those names are flat across\nthe whole host; two projects both declaring `web` get merged into one load\nbalancer and start receiving each other's traffic.\n\n`portta doctor` reports both.\n\n## Checklist\n\n- [ ] Docker Compose is at least 2.24.4\n- [ ] no routed service has `network_mode` or `container_name:`\n- [ ] HTTP services join their private networks and `portta`\n- [ ] datastores stay out of `portta`\n- [ ] Portta's generated overlay resets inherited host `ports:`\n- [ ] `portta prepare` prints a valid Runtime Plan\n- [ ] `portta urls` lists the expected hostnames\n- [ ] a second copy with a different `COMPOSE_PROJECT_NAME` runs alongside the first\n- [ ] `portta doctor` is clean\n\n## Verifying\n\n```bash\nportta urls --project <name>\ncurl -sI http://<name>-web.localhost | head -1\n\n# the real test: a second environment, in parallel\ngit worktree add ../<name>-issue1 -b issue1\ncd ../<name>-issue1\nportta up\nportta urls\n```\n\nBoth environments should be listed and both should answer.\n\n## Documenting it in the project\n\nCopy [`templates/project/PORTTA.md`](../../../templates/project/PORTTA.md)\ninto the project and adjust the names. It covers only what someone working on\nthat project needs: how to start it, its URLs, how to reach its database, how\nto run a second copy. The rules stay here.\n\n## Monorepos\n\nNothing changes: a monorepo is one Compose project with more services in it.\nSee [Use monorepos and worktrees](monorepos.md).\n\n## Optional: declaring what cannot be inferred\n\nThe panel works out a project's identity from the labels Compose already\ninjects: the project name, the working directory, and a worktree namespace when\nthe directory basename disagrees with the project name. Three optional labels\nsettle what that inference cannot. **All of them are optional, and a project\nthat sets none behaves exactly as it does today**, which is asserted in the test\nsuite rather than promised here.\n\n| Label | When it helps |\n|---|---|\n| `portta.project` | `COMPOSE_PROJECT_NAME` is a per-worktree namespace and five worktrees should group under one heading |\n| `portta.repo` | `owner/name` or a remote URL. Gives repository and commit links with no host-side Git at all |\n| `portta.git.root` | The repository root, when the Compose file is not at it (see [Use monorepos and worktrees](monorepos.md)) |\n\n```yaml\nservices:\n web:\n labels:\n - \"portta.project=base-empresarial\"\n - \"portta.repo=owner/base-empresarial\"\n```\n\nDeclare them on any one service; the first that does wins for the whole\nproject. `portta env analyze` reports which ones a project sets, and says\n\"none (inferred from the Compose labels)\" when it sets none, because that is\nthe normal answer rather than a finding.\n\nSee [ADR 0010](../../development/adr/0010-git-collected-on-the-host.md).\n\n## Runtime manifest\n\nFor an ambiguous project, use `portta init --service web:3000`, which stores\nthe same intent outside Git. A maintained project may commit this optional\nequivalent instead:\n\n```json\n{\n \"schemaVersion\": 1,\n \"driver\": \"compose\",\n \"compose\": { \"files\": [\"compose.yaml\"], \"profiles\": [] },\n \"services\": { \"web\": { \"http\": { \"port\": 3000 } } }\n}\n```\n\nSet `\"mode\": \"manual\"` only when the listed project-owned files already\ncontain a complete integration; Portta will validate and operate them without\nadding routing mutations.\n\n## Keeping the project runnable without the gateway\n\nThe overlay adds only networks, labels and port resets, so `docker compose up -d` on its own\nstill works; you just lose hostname routing. If a developer needs a published\nport for a one-off, that is their `compose.override.yaml`, not the shared file.\n\n## Optional: reaching this project's database by hostname\n\nEverything above is about HTTP. A project can additionally opt its datastores\ninto hostname routing, so they are reachable on the gateway's shared port\nwithout publishing one:\n\n```bash\ncp templates/overlays/09-tcp-routing.yaml compose.portta-tcp.yaml\ndocker compose -f compose.yaml -f compose.portta.yaml \\\n -f compose.portta-tcp.yaml up -d\n```\n\nIt needs `PORTTA_TCP=true` on the gateway, works for PostgreSQL and Redis,\nand requires TLS on the client. Read [Configure TCP routing](tcp-routing.md) first: it\nexplains what each protocol can and cannot do, and why MySQL is not on the\nlist.\n\n\n## Presentation needs no change to the project\n\nOnce a project is running, its name, description, primary service, collapsed\nservices, ordering and a short hostname are all adjustable from the panel's\n**Settings** control, and none of it touches the project. The values live in the\ngateway's own database, and a hostname alias becomes one router in a file the\ngateway owns. `git status` inside the clone stays clean.\n\nSee [Manage environments](environments.md).\n","text":"Add an existing project Your project stays where it is, in its own repository, started from its owndirectory. Adoption does not modify it: Portta keeps its generated runtimeoverlay under its own installation state. Start here It reads the project and reports what adoption would take: every service andwhat it looks like, the host ports it publishes and what already holds them,fixed container names and whether the host already holds them, datastores thatare published, whether the namespace is implicit, and whether the namespace isalready in use by another checkout. It writes nothing. When the Compose file isnot compose.yaml in that directory, name it with --file deploy/compose.yaml(relative to the path, or absolute): the file's directory becomes the projectdirectory. Then adopt it through the canonical workflow: adopt records an audited Runtime Plan outside the source repository. Itresolves the complete ordered Compose input set, validates it once before andonce after rendering the overlay, and derives a unique Compose project namefrom the workspace and current branch. runtime up, runtime down, runtime status, runtime logs andruntime config reuse exactly that plan. adopt persists optional exposure intent in Portta state. A project that wantsto version this intent may instead provide .portta/runtime.json; it containsonly files, profiles, manual/auto mode and service ports — never gatewaydomains, networks, labels, URLs or a concrete Compose namespace. prepare and init remain available for scripts that already use them, butnew integrations should use adopt: it is the one closed interface forinspection, explicit decisions and plan materialization. Decisions that need confirmation The dry run returns outcome: \"isolated\", \"manual\", or \"not_supported\".It also lists every unresolved choice under pending; no Compose or Porttaruntime file is written in this mode. A project with no HTTP surface is stillan isolated private environment. A multi-port service stays private until aspecific HTTP surface is named with --service name:port. Fixed resource names need an explicit acknowledgement because they can crossthe Compose namespace boundary: network_mode on a routed service is not supported, because Compose cannotalso attach it to the Portta network. container_name is never reset silently.Bind mounts and privileged settings remain visible findings: they are notrewritten by Portta and should be reviewed against the host's security policy. The contract A compatible project: uses Docker Compose 2.24.4 or newer; keeps its own private network and normal internal ports; avoids container_name: for isolated environments; does not route services that use network_mode; lets Portta remove inherited host ports: in auto mode. Nothing else. No Dockerfile changes, no directory moves, no shared base image. Optional Portta login The project owns its router labels, so Portta never adds authentication behindyour back. To protect a hostname, create its credential and opt the router intothe generated middleware: /__portta/auth is reserved on protected hosts for the login and logout routes.See Authentication for rotation, removal and API clients. The overlay Portta derives its runtime overlay outside the repository, so compose.yamlstill describes the application and the project still runs standalone withoutthe gateway: The Portta runtime wrapper supplies these files and -p <namespace>; do notadd its overlay to the project's .env or COMPOSE_FILE. When Compose reports only plain network names, Portta uses !override to makethe final routed-network set explicit. If the source has aliases or othernetwork metadata, it uses Compose's normal merge so that metadata is preserved. Working examples are independent repositories:portta-demo-a andportta-demo-b for the CI pair;also portta-demo-site(single web), portta-demo-shop(full stack with MySQL, Mailpit and RustFS),portta-demo-monorepo, andportta-demo-external (never adopted, for the panel'sExternal Docker section). Templates for the usual project shapes are intemplates/. Two rules that are easy to get wrong Generated labels use list form. Compose interpolates ${VAR} inside a list entrybut not inside a mapping key. In map form the Traefik service name stays theliteral ${COMPOSE_PROJECT_NAME} and every worktree of the project collapsesonto one load balancer. Prefix Traefik service names with the namespace. Those names are flat acrossthe whole host; two projects both declaring web get merged into one loadbalancer and start receiving each other's traffic. portta doctor reports both. Checklist [ ] Docker Compose is at least 2.24.4 [ ] no routed service has network_mode or container_name: [ ] HTTP services join their private networks and portta [ ] datastores stay out of portta [ ] Portta's generated overlay resets inherited host ports: [ ] portta prepare prints a valid Runtime Plan [ ] portta urls lists the expected hostnames [ ] a second copy with a different COMPOSE_PROJECT_NAME runs alongside the first [ ] portta doctor is clean Verifying Both environments should be listed and both should answer. Documenting it in the project Copy templates/project/PORTTA.mdinto the project and adjust the names. It covers only what someone working onthat project needs: how to start it, its URLs, how to reach its database, howto run a second copy. The rules stay here. Monorepos Nothing changes: a monorepo is one Compose project with more services in it.See Use monorepos and worktrees. Optional: declaring what cannot be inferred The panel works out a project's identity from the labels Compose alreadyinjects: the project name, the working directory, and a worktree namespace whenthe directory basename disagrees with the project name. Three optional labelssettle what that inference cannot. All of them are optional, and a projectthat sets none behaves exactly as it does today, which is asserted in the testsuite rather than promised here. Label When it helps portta.project COMPOSE_PROJECT_NAME is a per-worktree namespace and five worktrees should group under one heading portta.repo owner/name or a remote URL. Gives repository and commit links with no host-side Git at all portta.git.root The repository root, when the Compose file is not at it (see Use monorepos and worktrees) Declare them on any one service; the first that does wins for the wholeproject. portta env analyze reports which ones a project sets, and says\"none (inferred from the Compose labels)\" when it sets none, because that isthe normal answer rather than a finding. See ADR 0010. Runtime manifest For an ambiguous project, use portta init --service web:3000, which storesthe same intent outside Git. A maintained project may commit this optionalequivalent instead: Set \"mode\": \"manual\" only when the listed project-owned files alreadycontain a complete integration; Portta will validate and operate them withoutadding routing mutations. Keeping the project runnable without the gateway The overlay adds only networks, labels and port resets, so docker compose up -d on its ownstill works; you just lose hostname routing. If a developer needs a publishedport for a one-off, that is their compose.override.yaml, not the shared file. Optional: reaching this project's database by hostname Everything above is about HTTP. A project can additionally opt its datastoresinto hostname routing, so they are reachable on the gateway's shared portwithout publishing one: It needs PORTTA_TCP=true on the gateway, works for PostgreSQL and Redis,and requires TLS on the client. Read Configure TCP routing first: itexplains what each protocol can and cannot do, and why MySQL is not on thelist. Presentation needs no change to the project Once a project is running, its name, description, primary service, collapsedservices, ordering and a short hostname are all adjustable from the panel'sSettings control, and none of it touches the project. The values live in thegateway's own database, and a hostname alias becomes one router in a file thegateway owns. git status inside the clone stays clean. See Manage environments.","headings":[{"id":"add-an-existing-project","text":"Add an existing project","level":1,"line":0},{"id":"start-here","text":"Start here","level":2,"line":6},{"id":"decisions-that-need-confirmation","text":"Decisions that need confirmation","level":2,"line":50},{"id":"the-contract","text":"The contract","level":2,"line":77},{"id":"optional-portta-login","text":"Optional Portta login","level":2,"line":89},{"id":"the-overlay","text":"The overlay","level":2,"line":107},{"id":"two-rules-that-are-easy-to-get-wrong","text":"Two rules that are easy to get wrong","level":2,"line":164},{"id":"checklist","text":"Checklist","level":2,"line":177},{"id":"verifying","text":"Verifying","level":2,"line":189},{"id":"documenting-it-in-the-project","text":"Documenting it in the project","level":2,"line":204},{"id":"monorepos","text":"Monorepos","level":2,"line":211},{"id":"optional-declaring-what-cannot-be-inferred","text":"Optional: declaring what cannot be inferred","level":2,"line":216},{"id":"runtime-manifest","text":"Runtime manifest","level":2,"line":246},{"id":"keeping-the-project-runnable-without-the-gateway","text":"Keeping the project runnable without the gateway","level":2,"line":265},{"id":"optional-reaching-this-projects-database-by-hostname","text":"Optional: reaching this project's database by hostname","level":2,"line":271},{"id":"presentation-needs-no-change-to-the-project","text":"Presentation needs no change to the project","level":2,"line":289}],"kind":"markdown"},{"slug":"projects","title":"Manage projects","description":"Organize repositories and environments into Projects.","source":"docs/product/guides/projects.md","audience":"user","section":"Guides","category":"Projects and environments","url":"/docs/projects","markdown":"# Manage projects\n\n## Projects\n\nEvery page below is a route, not a tab held in memory: `/projects`,\n`/projects/<slug>`, `/projects/<slug>/tasks`, and so on. Each one is a link\nsomebody can paste, a bookmark that survives a reload, and a step the browser's\nback button walks. What a role may not do is not shown rather than shown\ndisabled — the exception is a task's own controls, which stay visible and\ninert, because a task's status is information a viewer came to read.\n\n![Projects as cards: each with its state, its counts, its last commit and the actions its state allows](../../images/panel-projects.png)\n\nThe products you recognise, as cards or as a table: repositories, open tasks,\nwho is working, running environments, health, last commit and last activity.\n**New project** creates one; a Project needs the panel's database and the page\nsays so when it is down. `Environments on this host` opens the list of every\nCompose project Docker is running, adopted or not.\n\nBoth views are places to act, not only to look. A card carries the one action\nits state allows — start what is stopped, stop what is running — and a menu\nwith the rest: tasks, repositories, environments, settings, archive, delete.\nAn action that could not change anything is not offered.\n\n![Projects as a table: state, environments, repositories, open and blocked tasks, agents and last activity, with selection and column controls](../../images/panel-projects-table.png)\n\nThe table sorts on any column, hides the ones a given host does not care about,\nand selects rows for a bulk start, stop, restart or archive. The arrangement is\nremembered per table. Nothing destructive happens without saying what it will\ndo: stopping a project names its environments and counts its containers,\nand deleting one asks for its slug and states what survives.\nThe panel classifies a Project's location against Projects Home by comparing\npaths the host scan reported; it never mounts Projects Home or any project\ndirectory.\n\nOpening a Project is the cockpit. The header carries its health, its tasks and\nsessions, an **Open / Test** menu for its primary environment and **New task**;\nbelow it, tabs that are URLs:\n\n| Tab | What it holds |\n|---|---|\n| **Overview** | Development status (in progress, blocked, next, active sessions), the repositories with their git state, the environments with their services and an Open / Test each, the recent activity, and the resources the project uses |\n| **Tasks** | The board and the list, below |\n| **Repositories** | Each repository as a row; **Add repository** offers what the host scan discovered, what the GitHub App was granted, or a path typed by hand |\n| **Environments** | The environments adopted, why each was adopted, and **Adopt** for one that was not |\n| **Activity** | The timeline: tasks moved, notes, sessions, environments started and stopped, commits the scan noticed |\n| **Settings** | Name, description, place under Projects Home, archive, and delete — which removes what only Portta holds and names it |\n\n\n## Repositories\n\n`/projects/<slug>/repositories/<id>` is one repository: branch, HEAD, the\nworking tree spelled out, ahead/behind, the remote, the directory on the host,\nand three tabs — the overview with open pull requests and the environments\nrunning from it, the last twenty commits, and the **instruction files** the\nhost collected (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/*.mdc`, …) with their\ncontent and whether they differ from HEAD.\n\nNone of it is live. `portta repos scan` collects it on the host and the\nmetrics watcher repeats it once a minute; every block says how old it is and\ncarries the command that refreshes it. See\n[ADR 0010](../../development/adr/0010-git-collected-on-the-host.md) and the amendment in\n[ADR 0032](../../development/adr/0032-portta-development-model.md).\n","text":"Manage projects Projects Every page below is a route, not a tab held in memory: /projects,/projects/<slug>, /projects/<slug>/tasks, and so on. Each one is a linksomebody can paste, a bookmark that survives a reload, and a step the browser'sback button walks. What a role may not do is not shown rather than showndisabled — the exception is a task's own controls, which stay visible andinert, because a task's status is information a viewer came to read. Projects as cards: each with its state, its counts, its last commit and the actions its state allows The products you recognise, as cards or as a table: repositories, open tasks,who is working, running environments, health, last commit and last activity.New project creates one; a Project needs the panel's database and the pagesays so when it is down. Environments on this host opens the list of everyCompose project Docker is running, adopted or not. Both views are places to act, not only to look. A card carries the one actionits state allows — start what is stopped, stop what is running — and a menuwith the rest: tasks, repositories, environments, settings, archive, delete.An action that could not change anything is not offered. Projects as a table: state, environments, repositories, open and blocked tasks, agents and last activity, with selection and column controls The table sorts on any column, hides the ones a given host does not care about,and selects rows for a bulk start, stop, restart or archive. The arrangement isremembered per table. Nothing destructive happens without saying what it willdo: stopping a project names its environments and counts its containers,and deleting one asks for its slug and states what survives.The panel classifies a Project's location against Projects Home by comparingpaths the host scan reported; it never mounts Projects Home or any projectdirectory. Opening a Project is the cockpit. The header carries its health, its tasks andsessions, an Open / Test menu for its primary environment and New task;below it, tabs that are URLs: Tab What it holds Overview Development status (in progress, blocked, next, active sessions), the repositories with their git state, the environments with their services and an Open / Test each, the recent activity, and the resources the project uses Tasks The board and the list, below Repositories Each repository as a row; Add repository offers what the host scan discovered, what the GitHub App was granted, or a path typed by hand Environments The environments adopted, why each was adopted, and Adopt for one that was not Activity The timeline: tasks moved, notes, sessions, environments started and stopped, commits the scan noticed Settings Name, description, place under Projects Home, archive, and delete — which removes what only Portta holds and names it Repositories /projects/<slug>/repositories/<id> is one repository: branch, HEAD, theworking tree spelled out, ahead/behind, the remote, the directory on the host,and three tabs — the overview with open pull requests and the environmentsrunning from it, the last twenty commits, and the instruction files thehost collected (AGENTS.md, CLAUDE.md, .cursor/rules/*.mdc, …) with theircontent and whether they differ from HEAD. None of it is live. portta repos scan collects it on the host and themetrics watcher repeats it once a minute; every block says how old it is andcarries the command that refreshes it. SeeADR 0010 and the amendment inADR 0032.","headings":[{"id":"manage-projects","text":"Manage projects","level":1,"line":0},{"id":"projects","text":"Projects","level":2,"line":2},{"id":"repositories","text":"Repositories","level":2,"line":49}],"kind":"markdown"},{"slug":"environments","title":"Manage environments","description":"Inspect and operate an isolated Compose environment.","source":"docs/product/guides/environments.md","audience":"user","section":"Guides","category":"Projects and environments","url":"/docs/environments","markdown":"# Manage environments\n\n## Environments\n\n`/environments` lists them; `/environments/<name>` is one, with `logs` and\n`settings` as routes beside it. The rail shows Docker, Network and Gateway only\nto somebody who holds `docker:read` or `gateway:read` — a navigation entry that\nwould answer 404 is a worse answer than no entry. Starting, stopping and\nrestarting need `environment:operate`; rebuilding, removing and forgetting need\n`environment:destroy`; the overrides form needs `environment:settings`. Reading\nlogs is `logs:read`, which a viewer has: they can watch what is happening and\nchange none of it.\n\n![The Environments page: every Compose project on this host, each as a table of its services with state, access, resources, runtime and actions](../../images/panel-environments.png)\n\n`/environments` lists every Compose project on this host, adopted or not,\neach as a table of its services. `/environments/<name>` is one environment:\n\n![One environment: its services as one table, with an Open / Test menu, resources and actions per row](../../images/panel-environment.png)\n\nThe header says how many services run, which Project adopted it and why,\nwhich repository and branch it runs from, and the task it is working on when\nthe panel can tell; then **Open / Test**, Start, Stop, Restart, Rebuild and\nthe two named removals. Three tabs:\n\n| Tab | What it holds |\n|---|---|\n| **Overview** | One row per service: state and health, the primary address with copy and open, **Open / Test**, CPU and memory from the host collector, image and container, uptime, and the actions that apply. A row opens a drawer with every endpoint, the connection details of a datastore, ports, networks, mounts, what Traefik says, the temporary share, the hostname alias, and the logs inline |\n| **Logs** | Every service at once, interleaved, or one of them; see [Logs across an environment](#logs-across-an-environment) |\n| **Settings** | Display name, description, primary service, collapsed services, pinned and archived, service notes and the hostname alias — nothing is written inside the project |\n\n**Open / Test** is the one menu that answers \"how do I reach this\": every\naddress by scope — local, LAN, VPN, public — with open and copy, and for a\ndatastore the loopback bridge to open or close, the host, the port and a\nconnection string. It is the same model the Access page manages\n([ADR 0024](../../development/adr/0024-capabilities-providers-endpoints.md)).\n\nAn old `/environments/<name>/services` opens the overview; `/…/git` opens\nthe repository the environment runs from.\n\n### Remembered environments\n\nAn environment whose containers were all removed does not vanish: the panel\nremembers where it ran (`working_dir`, the Compose files) and lists it as\n**remembered**, with no services. On a Project page it stays under its\nProject. Two things can happen to it: **Start**, which asks the runner for\n`docker compose up` with the remembered paths when `PORTTA_RUNNER=true`, or\nanswers with the exact command to run on the host when it is not; and\n**Forget**, which drops the row with its overrides and links, and touches\nnothing on the host. A live environment cannot be forgotten: stop and remove\nit first. Removing an environment (with or without its volumes) leaves it\nremembered, since its directory is still there; only removing the directory\nforgets it in the same step. `GET /api/environments?all=true` returns both kinds, each with\n`presence: live` or `presence: remembered`.\n\n### Logs across an environment\n\nThe Logs tab reads **every** service of the environment at once, interleaved by\nthe timestamp Docker already puts on each line, with the service name in front:\n\n```text\nweb | 10:00:01 listening on 3000\napi | 10:00:02 GET /health 200\npostgres | 10:00:03 ready to accept connections\n```\n\nA selector narrows the view to one service, and the choice is in the URL\n(`/environments/alpha/logs?service=api`), so a link opens on exactly what you were\nreading. Tail size, the text filter, follow, timestamps and copy are the same\ncontrols the service drawer has, because it is the same component; copying an\naggregated view prefixes each line with its service.\n\nServices are read concurrently on the server, and a source that could not be\nread is reported **beside** the ones that answered rather than replacing them: a\nstopped container is marked with its state, an unreadable one carries the\nreason, and four working services stay on screen. An unknown environment is a\n404; a known one whose sources all failed is a 200 that says why.\n\nThe aggregated default is 100 lines per service (200 when reading one), clamped\nto 2000 overall, so a ten-service environment cannot ask for twenty thousand\nlines. If a container logs through a driver that omits timestamps, the view\nsays ordering between services is approximate rather than pretending otherwise.\n\n**Out of scope, deliberately:** streaming over SSE or WebSocket, retention,\nindexing, structured-log parsing, level filtering and download-as-file. This is\na bounded tail on a three-second poll, and it is meant to stay one.\n\n### Naming an environment without touching it\n\nA cloned third-party repository arrives as `awesome-thing-svc-1` on\n`awesome-thing-svc-1.localhost`, with five services listed flat. The\nenvironment's **Settings** tab adjusts all of that from the panel, and writes\nnothing inside the project — no file, no label, no dependency, no commit.\n`git status` in the clone stays clean after using every control here.\n\n| Override | Effect |\n|---|---|\n| Display name | The heading and the sort key. The derived name is still shown beside it |\n| Description | A line under the heading |\n| Primary service | The service the environment's Open / Test targets first |\n| Collapsed services | Folded away by default, never removed |\n| Pinned / archived | Ordering and default filtering in the list |\n| Service note | A line on the service row |\n| **Hostname alias** | **An additional hostname, routed by Traefik** |\n\nEverything except the alias is presentation, kept in the gateway's own\ndatabase. **Nothing is ever only-renamed**: the derived name and the derived\nhostname stay on screen next to the override, so a hostname that behaves oddly\ncan still be traced back to the label that produced it.\n\nOverrides key on `COMPOSE_PROJECT_NAME`, so `storefront` and\n`storefront-issue59` are two environments with two sets of overrides, and a new\nworktree starts blank. That is deliberate: two worktrees must never contend for\none hostname.\n\nWith PostgreSQL stopped, every environment renders exactly as it does without\nany persistence at all, and the override endpoints answer `503` with a hint.\nThe feature disappears; nothing else notices.\n\n### A hostname alias is a nickname, not a rename\n\n```text\nalpha-web.localhost derived, still answering\nshop.localhost alias, answering too\n```\n\nSetting an alias writes one router into `portta-aliases.yaml`, the third\nand last file the panel may write in Traefik's dynamic directory\n([ADR 0011](../../development/adr/0011-bounded-traefik-write-surface.md)). Traefik\nhot-reloads it: no container is recreated and the gateway is not restarted.\n\n**Both hostnames answer.** The panel cannot rewrite a label on a running\ncontainer, and would not restart someone's environment to change a nickname, so\nan alias can only ever be additional. The UI shows both, everywhere.\n\nAliasing **refuses** rather than warns, and every refusal happens before\nanything is written:\n\n- a hostname a running container already derives or declares;\n- a hostname another alias already took;\n- a hostname outside `PORTTA_DOMAIN`, `PRIVATE_DOMAIN` or `PUBLIC_DOMAIN`\n — the gateway will not mint an address it cannot serve;\n- a service whose `kind` is not `http`: a database is reached through\n [Configure TCP routing](tcp-routing.md), not by an HTTP router;\n- a service off the shared network, or one that never enabled Traefik;\n- a service with no unambiguous HTTP port. The project's own\n `traefik.http.services.*.loadbalancer.server.port` label is used when present\n and a single exposed port otherwise; anything else is refused rather than\n guessed, because a guessed port produces a router that silently 502s.\n\nThe database row and the generated file are written as one operation, and a\nfailed file write rolls the row back, so the panel and Traefik cannot disagree\nabout what answers.\n\nThe CLI reads the same file, so the two tools never contradict each other:\n\n```bash\nportta urls # aliases are listed and marked as such\nportta doctor # flags an alias whose target container is gone\n```\n\nAnything the panel refuses can still be written by hand into\n`config/traefik/dynamic/` — that file is yours, and the panel never touches it.\n\n### Sharing it, temporarily\n\nThe Exposure section on a service offers three states: **private** (the absence\nof a share, and the default), **protected** (an additional hostname behind a\ngenerated password) and **public** (an additional hostname with none, refused\nunless public access is already on).\n\nEvery share carries an expiry, the password is shown exactly once and stored\nonly as a hash, and revoking one deletes a block from a generated file. The\nproject's own router, labels and configuration are never touched.\n`portta share list|revoke|gc` manages the same objects from the host. See\n[Share a service](sharing.md).\n\n### Why a route behaves like this\n\nOpening a service shows what Traefik itself says about it, next to what its\nlabels say: the router it built, the rule, the entrypoints, the middlewares, the\nbackend it resolved, and its status with Traefik's own error text when it\nrefused one.\n\n```text\nTraefik storefront-web@docker enabled websecure dashboard →\n Host(`storefront-web.dev.example.com`)\n middlewares: portta-secure-headers@file\n → http://172.18.0.7:3000\n```\n\nThis is the one question labels cannot answer. The panel derives hostnames the\nsame way Traefik does and is right about them, which is exactly why \"the labels\nlook right and it still 404s\" had nowhere to go.\n\nIt needs the Traefik API, which means `PORTTA_DASHBOARD=true`, and that is\noff by default. When it is off the panel says **the API was not asked** rather\nthan implying the labels were confirmed, and everything else is unchanged.\n`doctor` gains two checks when it is on: a routed service Traefik never built a\nrouter for, and a router Traefik refused, quoted.\n\nThe read has its own timeout and its own cache and never runs while a page is\nrendering, so a slow or dead Traefik costs nothing but this block. The dashboard\nis linked to, never embedded: it is a good tool and duplicating it would need\nthe insecure-mode API exposed more widely than it already is. See\n[ADR 0011](../../development/adr/0011-bounded-traefik-write-surface.md), and\n[Security](../concepts/security.md) for what enabling that API costs.\n","text":"Manage environments Environments /environments lists them; /environments/<name> is one, with logs andsettings as routes beside it. The rail shows Docker, Network and Gateway onlyto somebody who holds docker:read or gateway:read — a navigation entry thatwould answer 404 is a worse answer than no entry. Starting, stopping andrestarting need environment:operate; rebuilding, removing and forgetting needenvironment:destroy; the overrides form needs environment:settings. Readinglogs is logs:read, which a viewer has: they can watch what is happening andchange none of it. The Environments page: every Compose project on this host, each as a table of its services with state, access, resources, runtime and actions /environments lists every Compose project on this host, adopted or not,each as a table of its services. /environments/<name> is one environment: One environment: its services as one table, with an Open / Test menu, resources and actions per row The header says how many services run, which Project adopted it and why,which repository and branch it runs from, and the task it is working on whenthe panel can tell; then Open / Test, Start, Stop, Restart, Rebuild andthe two named removals. Three tabs: Tab What it holds Overview One row per service: state and health, the primary address with copy and open, Open / Test, CPU and memory from the host collector, image and container, uptime, and the actions that apply. A row opens a drawer with every endpoint, the connection details of a datastore, ports, networks, mounts, what Traefik says, the temporary share, the hostname alias, and the logs inline Logs Every service at once, interleaved, or one of them; see Logs across an environment Settings Display name, description, primary service, collapsed services, pinned and archived, service notes and the hostname alias — nothing is written inside the project Open / Test is the one menu that answers \"how do I reach this\": everyaddress by scope — local, LAN, VPN, public — with open and copy, and for adatastore the loopback bridge to open or close, the host, the port and aconnection string. It is the same model the Access page manages(ADR 0024). An old /environments/<name>/services opens the overview; /…/git opensthe repository the environment runs from. Remembered environments An environment whose containers were all removed does not vanish: the panelremembers where it ran (working_dir, the Compose files) and lists it asremembered, with no services. On a Project page it stays under itsProject. Two things can happen to it: Start, which asks the runner fordocker compose up with the remembered paths when PORTTA_RUNNER=true, oranswers with the exact command to run on the host when it is not; andForget, which drops the row with its overrides and links, and touchesnothing on the host. A live environment cannot be forgotten: stop and removeit first. Removing an environment (with or without its volumes) leaves itremembered, since its directory is still there; only removing the directoryforgets it in the same step. GET /api/environments?all=true returns both kinds, each withpresence: live or presence: remembered. Logs across an environment The Logs tab reads every service of the environment at once, interleaved bythe timestamp Docker already puts on each line, with the service name in front: A selector narrows the view to one service, and the choice is in the URL(/environments/alpha/logs?service=api), so a link opens on exactly what you werereading. Tail size, the text filter, follow, timestamps and copy are the samecontrols the service drawer has, because it is the same component; copying anaggregated view prefixes each line with its service. Services are read concurrently on the server, and a source that could not beread is reported beside the ones that answered rather than replacing them: astopped container is marked with its state, an unreadable one carries thereason, and four working services stay on screen. An unknown environment is a404; a known one whose sources all failed is a 200 that says why. The aggregated default is 100 lines per service (200 when reading one), clampedto 2000 overall, so a ten-service environment cannot ask for twenty thousandlines. If a container logs through a driver that omits timestamps, the viewsays ordering between services is approximate rather than pretending otherwise. Out of scope, deliberately: streaming over SSE or WebSocket, retention,indexing, structured-log parsing, level filtering and download-as-file. This isa bounded tail on a three-second poll, and it is meant to stay one. Naming an environment without touching it A cloned third-party repository arrives as awesome-thing-svc-1 onawesome-thing-svc-1.localhost, with five services listed flat. Theenvironment's Settings tab adjusts all of that from the panel, and writesnothing inside the project — no file, no label, no dependency, no commit.git status in the clone stays clean after using every control here. Override Effect Display name The heading and the sort key. The derived name is still shown beside it Description A line under the heading Primary service The service the environment's Open / Test targets first Collapsed services Folded away by default, never removed Pinned / archived Ordering and default filtering in the list Service note A line on the service row Hostname alias An additional hostname, routed by Traefik Everything except the alias is presentation, kept in the gateway's owndatabase. Nothing is ever only-renamed: the derived name and the derivedhostname stay on screen next to the override, so a hostname that behaves oddlycan still be traced back to the label that produced it. Overrides key on COMPOSE_PROJECT_NAME, so storefront andstorefront-issue59 are two environments with two sets of overrides, and a newworktree starts blank. That is deliberate: two worktrees must never contend forone hostname. With PostgreSQL stopped, every environment renders exactly as it does withoutany persistence at all, and the override endpoints answer 503 with a hint.The feature disappears; nothing else notices. A hostname alias is a nickname, not a rename Setting an alias writes one router into portta-aliases.yaml, the thirdand last file the panel may write in Traefik's dynamic directory(ADR 0011). Traefikhot-reloads it: no container is recreated and the gateway is not restarted. Both hostnames answer. The panel cannot rewrite a label on a runningcontainer, and would not restart someone's environment to change a nickname, soan alias can only ever be additional. The UI shows both, everywhere. Aliasing refuses rather than warns, and every refusal happens beforeanything is written: a hostname a running container already derives or declares; a hostname another alias already took; a hostname outside PORTTA_DOMAIN, PRIVATE_DOMAIN or PUBLIC_DOMAIN— the gateway will not mint an address it cannot serve; a service whose kind is not http: a database is reached throughConfigure TCP routing, not by an HTTP router; a service off the shared network, or one that never enabled Traefik; a service with no unambiguous HTTP port. The project's owntraefik.http.services.*.loadbalancer.server.port label is used when presentand a single exposed port otherwise; anything else is refused rather thanguessed, because a guessed port produces a router that silently 502s. The database row and the generated file are written as one operation, and afailed file write rolls the row back, so the panel and Traefik cannot disagreeabout what answers. The CLI reads the same file, so the two tools never contradict each other: Anything the panel refuses can still be written by hand intoconfig/traefik/dynamic/ — that file is yours, and the panel never touches it. Sharing it, temporarily The Exposure section on a service offers three states: private (the absenceof a share, and the default), protected (an additional hostname behind agenerated password) and public (an additional hostname with none, refusedunless public access is already on). Every share carries an expiry, the password is shown exactly once and storedonly as a hash, and revoking one deletes a block from a generated file. Theproject's own router, labels and configuration are never touched.portta share list|revoke|gc manages the same objects from the host. SeeShare a service. Why a route behaves like this Opening a service shows what Traefik itself says about it, next to what itslabels say: the router it built, the rule, the entrypoints, the middlewares, thebackend it resolved, and its status with Traefik's own error text when itrefused one. This is the one question labels cannot answer. The panel derives hostnames thesame way Traefik does and is right about them, which is exactly why \"the labelslook right and it still 404s\" had nowhere to go. It needs the Traefik API, which means PORTTA_DASHBOARD=true, and that isoff by default. When it is off the panel says the API was not asked ratherthan implying the labels were confirmed, and everything else is unchanged.doctor gains two checks when it is on: a routed service Traefik never built arouter for, and a router Traefik refused, quoted. The read has its own timeout and its own cache and never runs while a page isrendering, so a slow or dead Traefik costs nothing but this block. The dashboardis linked to, never embedded: it is a good tool and duplicating it would needthe insecure-mode API exposed more widely than it already is. SeeADR 0011, andSecurity for what enabling that API costs.","headings":[{"id":"manage-environments","text":"Manage environments","level":1,"line":0},{"id":"environments","text":"Environments","level":2,"line":2},{"id":"remembered-environments","text":"Remembered environments","level":3,"line":40},{"id":"logs-across-an-environment","text":"Logs across an environment","level":3,"line":55},{"id":"naming-an-environment-without-touching-it","text":"Naming an environment without touching it","level":3,"line":87},{"id":"a-hostname-alias-is-a-nickname-not-a-rename","text":"A hostname alias is a nickname, not a rename","level":3,"line":119},{"id":"sharing-it-temporarily","text":"Sharing it, temporarily","level":3,"line":164},{"id":"why-a-route-behaves-like-this","text":"Why a route behaves like this","level":3,"line":177}],"kind":"markdown"},{"slug":"services","title":"Manage services","description":"Inspect HTTP services, containers and their endpoints.","source":"docs/product/guides/services.md","audience":"user","section":"Guides","category":"Projects and environments","url":"/docs/services","markdown":"# Manage services\n\n## Services\n\nEvery service of every integrated project as a flat, filterable list: image,\ntype, status, health, container port, and the addresses it answers on, split\ninto **Local**, **VPN** and **Public**. Every address has a copy button.\n\nAddresses come from the same Docker labels Traefik routes on, so what the panel\nprints is what Traefik serves. An explicit ``Host(`...`)`` label wins over the\nderived hostname, exactly as it does inside Traefik.\n\n![The Services page: every service of every integrated project in one filterable table, with its type, health, container port and the address it answers on](../../images/panel-services.png)\n\n\n## Docker\n\nEvery container on the host, in four clearly separated sections:\n\n| Section | What it means |\n|---|---|\n| **Portta** | The gateway's own infrastructure. Managed by the CLI, not from here |\n| **Integrated projects** | Compose projects connected to the gateway |\n| **External Docker** | Compose projects the gateway does not manage |\n| **Standalone containers** | Started by hand, outside any Compose project |\n\nThey are never mixed into one list. An external container is shown for\ndiagnosis, not because the gateway has any opinion about it: no URLs, no DNS,\nno bridges, no gateway actions. Just what it is, what it holds, and the few\noperations below.\n\n![The top of the Docker page: counts by section, and Portta section listing the gateway's own containers](../../images/panel-docker.png)\n\nBelow the sections, a host summary: engine and resources, container counts by\nsection, networks, and every published port with the container holding it.\nPorts claimed by two containers are flagged, which is usually the answer to\n\"why will this not start\".\n\n![Further down the Docker page: External Docker, Standalone containers, and the published ports table flagging 5432 as claimed by two containers at once](../../images/panel-docker-external.png)\n\nFilters: All / Portta / Integrated / External / Standalone, crossed with\nAny state / Running / Stopped / Unhealthy, plus a search over container name,\nimage, project, service and hostname.\n","text":"Manage services Services Every service of every integrated project as a flat, filterable list: image,type, status, health, container port, and the addresses it answers on, splitinto Local, VPN and Public. Every address has a copy button. Addresses come from the same Docker labels Traefik routes on, so what the panelprints is what Traefik serves. An explicit Host(`...`) label wins over thederived hostname, exactly as it does inside Traefik. The Services page: every service of every integrated project in one filterable table, with its type, health, container port and the address it answers on Docker Every container on the host, in four clearly separated sections: Section What it means Portta The gateway's own infrastructure. Managed by the CLI, not from here Integrated projects Compose projects connected to the gateway External Docker Compose projects the gateway does not manage Standalone containers Started by hand, outside any Compose project They are never mixed into one list. An external container is shown fordiagnosis, not because the gateway has any opinion about it: no URLs, no DNS,no bridges, no gateway actions. Just what it is, what it holds, and the fewoperations below. The top of the Docker page: counts by section, and Portta section listing the gateway's own containers Below the sections, a host summary: engine and resources, container counts bysection, networks, and every published port with the container holding it.Ports claimed by two containers are flagged, which is usually the answer to\"why will this not start\". Further down the Docker page: External Docker, Standalone containers, and the published ports table flagging 5432 as claimed by two containers at once Filters: All / Portta / Integrated / External / Standalone, crossed withAny state / Running / Stopped / Unhealthy, plus a search over container name,image, project, service and hostname.","headings":[{"id":"manage-services","text":"Manage services","level":1,"line":0},{"id":"services","text":"Services","level":2,"line":2},{"id":"docker","text":"Docker","level":2,"line":15}],"kind":"markdown"},{"slug":"container-console","title":"Open a console in a project container","description":"Open a scoped interactive shell in a running project container.","source":"docs/product/guides/container-console.md","audience":"user","section":"Guides","category":"Projects and environments","url":"/docs/container-console","markdown":"# Open a console in a project container\n\nOpen an environment, choose a running service and select **Console**. The panel\nopens an interactive `/bin/bash` session when available and falls back to\n`/bin/sh`. Images with neither shell show a readable error.\n\nThe console runs as the image's configured user. It is never privileged and\ndoes not accept a user or command override. Use it to inspect the environment,\napplication configuration, service DNS and tools already shipped in the image.\nIt is not a host shell and cannot enter Portta's own gateway containers.\n\nOnly owners and administrators hold `container:console`. Developers may still\nstart, stop and restart project containers but do not receive a shell by\ndefault; viewers never do. Local agents do not receive this permission by\ndefault either.\n\nClose the console when finished. It also closes after 15 minutes without input\nor output and has a two-hour maximum. Portta interrupts the foreground command,\nexits the fixed shell and verifies the Docker exec state before releasing its\nconnection. Opening and closing are audited with principal, project, container\nand duration. Commands and output are not recorded.\n\nThe shell can read everything its container can read, including environment\nsecrets and writable mounts. Treat access to the panel accordingly and keep a\nremote panel behind its required authentication and intended private network.\n","text":"Open a console in a project container Open an environment, choose a running service and select Console. The panelopens an interactive /bin/bash session when available and falls back to/bin/sh. Images with neither shell show a readable error. The console runs as the image's configured user. It is never privileged anddoes not accept a user or command override. Use it to inspect the environment,application configuration, service DNS and tools already shipped in the image.It is not a host shell and cannot enter Portta's own gateway containers. Only owners and administrators hold container:console. Developers may stillstart, stop and restart project containers but do not receive a shell bydefault; viewers never do. Local agents do not receive this permission bydefault either. Close the console when finished. It also closes after 15 minutes without inputor output and has a two-hour maximum. Portta interrupts the foreground command,exits the fixed shell and verifies the Docker exec state before releasing itsconnection. Opening and closing are audited with principal, project, containerand duration. Commands and output are not recorded. The shell can read everything its container can read, including environmentsecrets and writable mounts. Treat access to the panel accordingly and keep aremote panel behind its required authentication and intended private network.","headings":[{"id":"open-a-console-in-a-project-container","text":"Open a console in a project container","level":1,"line":0}],"kind":"markdown"},{"slug":"local-development","title":"Develop applications locally","description":"Use the gateway for applications on your workstation.","source":"docs/product/guides/local-development.md","audience":"user","section":"Guides","category":"Projects and environments","url":"/docs/local-development","markdown":"# Develop applications locally\n\n## Requirements\n\n**macOS**: [OrbStack](https://orbstack.dev) or Docker Desktop, Git, a shell.\n**Linux**: Docker Engine 24+, the Compose v2 plugin, Git, a shell.\n\nOrbStack is the recommended runtime on macOS: it starts faster and uses much\nless memory than Docker Desktop. The gateway does **not** depend on any\nOrbStack-specific API. Anything OrbStack-only is an optimisation the gateway\ndetects and offers, never something it requires.\n\nNote the versions: Docker Compose **v2** (the `docker compose` plugin). The\nstandalone `docker-compose` v1 binary is not supported.\n\n\n## Setup\n\n```bash\ngit clone git@github.com:fabioassuncao/portta.git\ncd portta\ncp .env.example .env\n\n./bin/portta bootstrap\n./bin/portta up local\n./bin/portta doctor\n```\n\n`bootstrap` is idempotent, so run it whenever you want a health check with\nrepairs to the parts it owns. It never deletes anything.\n\nPut the CLI on your `PATH` so you can call it from any project directory:\n\n```bash\nln -s \"$PWD/bin/portta\" /usr/local/bin/portta\n```\n\n\n## Why `.localhost` needs no configuration\n\n`localhost` is reserved by [RFC 6761](https://www.rfc-editor.org/rfc/rfc6761),\nwhich requires resolvers to map it, **and its subdomains**, to loopback\nwithout consulting DNS.\n\nIn practice that means `demo-a-web.localhost` resolves to `127.0.0.1` with:\n\n- no `/etc/hosts` editing,\n- no `dnsmasq`,\n- no local DNS daemon,\n- nothing to do when a new project or worktree appears.\n\nThis works out of the box in Safari, Chrome, Firefox and Edge, and in `curl`\non macOS and modern Linux distributions.\n\n**Known limits.** A few tools resolve names themselves and do not implement the\nRFC. Older Go binaries and some JVM HTTP clients are the usual suspects; musl\nlibc historically did not special-case it either, so a plain Alpine container\nmay fail to resolve `*.localhost` even though your browser can. If you hit\nthis, either use the container-to-container name over the shared network, or\nset `PORTTA_DOMAIN` to a real domain that resolves to `127.0.0.1`.\n\n`doctor` probes this and tells you if it cannot confirm resolution.\n\n\n## Everyday use\n\n```bash\nportta status\nportta urls\nportta logs\nportta doctor\n```\n\nStart application services with Compose from their own project directory.\n\n\n## Running several environments\n\n`COMPOSE_PROJECT_NAME` is the namespace:\n\n```bash\ncd ~/Projects/base-empresarial\ndocker compose -f compose.yaml -f compose.portta.yaml up -d\n# -> base-empresarial-web.localhost\n\ngit worktree add ../base-empresarial-issue59 issue59\ncd ../base-empresarial-issue59\nCOMPOSE_PROJECT_NAME=base-empresarial-issue59 \\\n docker compose -f compose.yaml -f compose.portta.yaml up -d\n# -> base-empresarial-issue59-web.localhost\n```\n\nBoth run at once, each with its own containers, network, volumes and database.\nPutting `COMPOSE_PROJECT_NAME` in the worktree's `.env` saves repeating it.\n\n\n## HTTPS locally (optional)\n\nPlain HTTP works with no setup, and for most local work that is the right\nchoice. HTTPS is worth enabling when you need Secure cookies, service workers,\nor anything else gated behind a secure context.\n\nIt is opt-in and never required. See [Configure DNS and TLS](dns-and-tls.md).\n\n\n## If port 80 is taken\n\nRun `portta doctor` and identify the owner. Do not stop another environment to free its port. Coordinate gateway listeners with the host operator.\n","text":"Develop applications locally Requirements macOS: OrbStack or Docker Desktop, Git, a shell.Linux: Docker Engine 24+, the Compose v2 plugin, Git, a shell. OrbStack is the recommended runtime on macOS: it starts faster and uses muchless memory than Docker Desktop. The gateway does not depend on anyOrbStack-specific API. Anything OrbStack-only is an optimisation the gatewaydetects and offers, never something it requires. Note the versions: Docker Compose v2 (the docker compose plugin). Thestandalone docker-compose v1 binary is not supported. Setup bootstrap is idempotent, so run it whenever you want a health check withrepairs to the parts it owns. It never deletes anything. Put the CLI on your PATH so you can call it from any project directory: Why .localhost needs no configuration localhost is reserved by RFC 6761,which requires resolvers to map it, and its subdomains, to loopbackwithout consulting DNS. In practice that means demo-a-web.localhost resolves to 127.0.0.1 with: no /etc/hosts editing, no dnsmasq, no local DNS daemon, nothing to do when a new project or worktree appears. This works out of the box in Safari, Chrome, Firefox and Edge, and in curlon macOS and modern Linux distributions. Known limits. A few tools resolve names themselves and do not implement theRFC. Older Go binaries and some JVM HTTP clients are the usual suspects; musllibc historically did not special-case it either, so a plain Alpine containermay fail to resolve *.localhost even though your browser can. If you hitthis, either use the container-to-container name over the shared network, orset PORTTA_DOMAIN to a real domain that resolves to 127.0.0.1. doctor probes this and tells you if it cannot confirm resolution. Everyday use Start application services with Compose from their own project directory. Running several environments COMPOSE_PROJECT_NAME is the namespace: Both run at once, each with its own containers, network, volumes and database.Putting COMPOSE_PROJECT_NAME in the worktree's .env saves repeating it. HTTPS locally (optional) Plain HTTP works with no setup, and for most local work that is the rightchoice. HTTPS is worth enabling when you need Secure cookies, service workers,or anything else gated behind a secure context. It is opt-in and never required. See Configure DNS and TLS. If port 80 is taken Run portta doctor and identify the owner. Do not stop another environment to free its port. Coordinate gateway listeners with the host operator.","headings":[{"id":"develop-applications-locally","text":"Develop applications locally","level":1,"line":0},{"id":"requirements","text":"Requirements","level":2,"line":2},{"id":"setup","text":"Setup","level":2,"line":16},{"id":"why-localhost-needs-no-configuration","text":"Why .localhost needs no configuration","level":2,"line":38},{"id":"everyday-use","text":"Everyday use","level":2,"line":64},{"id":"running-several-environments","text":"Running several environments","level":2,"line":76},{"id":"https-locally-optional","text":"HTTPS locally (optional)","level":2,"line":96},{"id":"if-port-80-is-taken","text":"If port 80 is taken","level":2,"line":105}],"kind":"markdown"},{"slug":"monorepos","title":"Use monorepos and worktrees","description":"Keep worktrees and multi-service repositories isolated.","source":"docs/product/guides/monorepos.md","audience":"user","section":"Guides","category":"Projects and environments","url":"/docs/monorepos","markdown":"# Use monorepos and worktrees\n\nA monorepo needs nothing special. It is one Compose project with more services\nin it, and the gateway already keys everything off the Compose project name.\n\n```text\nbase-empresarial/\n apps/\n web/ Dockerfile\n admin/ Dockerfile\n services/\n api/ Dockerfile\n worker/ Dockerfile\n importer/ Dockerfile\n compose.yaml\n compose.portta.yaml\n```\n\nOne namespace, one private network, one set of volumes, and a hostname per\nservice that serves HTTP:\n\n```text\nbase-empresarial-web.localhost\nbase-empresarial-admin.localhost\nbase-empresarial-api.localhost\n```\n\n`worker` and `importer` serve no HTTP, so they get no networks and no labels\nand keep running exactly as they did.\n\nSee [`templates/overlays/06-monorepo.yaml`](../../../templates/overlays/06-monorepo.yaml).\n\n## What stays in the monorepo\n\nEverything. Dockerfiles, build contexts, volumes, the Compose file, the release\nprocess. The gateway centralises **routing**, not builds and not deployment. It\nnever needs to know your directory layout, and there is nothing to configure in\nthe gateway when you add an app.\n\nAdding one is a three-line change to your own overlay:\n\n```yaml\n new-app:\n networks: [default, portta]\n labels:\n - \"traefik.enable=true\"\n - \"traefik.docker.network=${PORTTA_NETWORK:-portta}\"\n - \"traefik.http.services.${COMPOSE_PROJECT_NAME}-new-app.loadbalancer.server.port=3000\"\n```\n\n## One namespace or several?\n\n**One** is the default and almost always right. The services share a private\nnetwork, so they reach each other by service name, and one `docker compose up`\nbrings the whole thing up.\n\n**Several**, a separate Compose project per app, makes sense only when apps\nare genuinely independent: separate databases, separate lifecycles, and you\nroutinely run one without the others. The cost is real: they no longer share a\nprivate network, so cross-app calls have to go through the gateway by hostname,\nand you manage several namespaces by hand.\n\nIf you do split:\n\n```bash\nCOMPOSE_PROJECT_NAME=base-empresarial-web docker compose -f apps/web/compose.yaml up -d\nCOMPOSE_PROJECT_NAME=base-empresarial-api docker compose -f services/api/compose.yaml up -d\n```\n\nNote the hostnames become `base-empresarial-web-web.localhost`, with the\nnamespace and the service name both in there. Usually a reason to keep one\nnamespace.\n\n## Worktrees of a monorepo\n\nIdentical to any other project: one variable.\n\n```bash\ngit worktree add ../base-empresarial-issue59 issue59\ncd ../base-empresarial-issue59\necho \"COMPOSE_PROJECT_NAME=base-empresarial-issue59\" >> .env\ndocker compose -f compose.yaml -f compose.portta.yaml up -d\n```\n\nEvery app in the worktree gets its own hostname, and the whole worktree gets\nits own database. Both copies run at once.\n\n## Shared build layers\n\nMonorepos often share a base image between apps. That is a build concern and\nthe gateway is not involved, but it does interact with namespaces in one way:\nif you tag a shared base image with a fixed name, two worktrees building\nconcurrently will race to overwrite it.\n\nTag per namespace, or build the base once and reference it read-only:\n\n```yaml\n web:\n build:\n context: .\n dockerfile: apps/web/Dockerfile\n image: ${COMPOSE_PROJECT_NAME}-web\n```\n\n## Analyzing one\n\n```bash\nportta env analyze /path/to/monorepo\n```\n\nIt reads the resolved Compose model, so it sees every service regardless of\nwhere its Dockerfile lives, classifies each, and proposes only the ones that\nlook like they serve HTTP.\n\n## A monorepo is one repository in one Project\n\nThe Project model does not treat a monorepo as a special case: it is a\nProject that owns exactly one repository. What differs is what runs against\nit — several worktrees, each its own `COMPOSE_PROJECT_NAME`, each adopted by\nthe same Project.\n\n```text\nProject \"Plataforma\"\n├── repositories acme/plataforma\n└── environments plataforma (label)\n plataforma-issue182 (repo-match)\n plataforma-issue190 (repo-match)\n```\n\nThe worktrees stay independent environments, exactly as they are today: their\noverrides do not inherit, their hostnames do not collide, and stopping one\ntouches none of the others. The Project is what says they are the same\nproduct.\n\nSee [GitHub synchronization](../concepts/github-sync.md#projects-repositories-and-the-environments-that-belong-to-them).\n","text":"Use monorepos and worktrees A monorepo needs nothing special. It is one Compose project with more servicesin it, and the gateway already keys everything off the Compose project name. One namespace, one private network, one set of volumes, and a hostname perservice that serves HTTP: worker and importer serve no HTTP, so they get no networks and no labelsand keep running exactly as they did. See templates/overlays/06-monorepo.yaml. What stays in the monorepo Everything. Dockerfiles, build contexts, volumes, the Compose file, the releaseprocess. The gateway centralises routing, not builds and not deployment. Itnever needs to know your directory layout, and there is nothing to configure inthe gateway when you add an app. Adding one is a three-line change to your own overlay: One namespace or several? One is the default and almost always right. The services share a privatenetwork, so they reach each other by service name, and one docker compose upbrings the whole thing up. Several, a separate Compose project per app, makes sense only when appsare genuinely independent: separate databases, separate lifecycles, and youroutinely run one without the others. The cost is real: they no longer share aprivate network, so cross-app calls have to go through the gateway by hostname,and you manage several namespaces by hand. If you do split: Note the hostnames become base-empresarial-web-web.localhost, with thenamespace and the service name both in there. Usually a reason to keep onenamespace. Worktrees of a monorepo Identical to any other project: one variable. Every app in the worktree gets its own hostname, and the whole worktree getsits own database. Both copies run at once. Shared build layers Monorepos often share a base image between apps. That is a build concern andthe gateway is not involved, but it does interact with namespaces in one way:if you tag a shared base image with a fixed name, two worktrees buildingconcurrently will race to overwrite it. Tag per namespace, or build the base once and reference it read-only: Analyzing one It reads the resolved Compose model, so it sees every service regardless ofwhere its Dockerfile lives, classifies each, and proposes only the ones thatlook like they serve HTTP. A monorepo is one repository in one Project The Project model does not treat a monorepo as a special case: it is aProject that owns exactly one repository. What differs is what runs againstit — several worktrees, each its own COMPOSE_PROJECT_NAME, each adopted bythe same Project. The worktrees stay independent environments, exactly as they are today: theiroverrides do not inherit, their hostnames do not collide, and stopping onetouches none of the others. The Project is what says they are the sameproduct. See GitHub synchronization.","headings":[{"id":"use-monorepos-and-worktrees","text":"Use monorepos and worktrees","level":1,"line":0},{"id":"what-stays-in-the-monorepo","text":"What stays in the monorepo","level":2,"line":32},{"id":"one-namespace-or-several","text":"One namespace or several?","level":2,"line":50},{"id":"worktrees-of-a-monorepo","text":"Worktrees of a monorepo","level":2,"line":73},{"id":"shared-build-layers","text":"Shared build layers","level":2,"line":87},{"id":"analyzing-one","text":"Analyzing one","level":2,"line":104},{"id":"a-monorepo-is-one-repository-in-one-project","text":"A monorepo is one repository in one Project","level":2,"line":114}],"kind":"markdown"},{"slug":"dns-and-tls","title":"Configure DNS and TLS","description":"Configure DNS records and certificates for project hostnames.","source":"docs/product/guides/dns-and-tls.md","audience":"user","section":"Guides","category":"Domains and networking","url":"/docs/dns-and-tls","markdown":"# Configure DNS and TLS\n\nThe panel opens these controls directly at\n[TLS settings](http://127.0.0.1:8081/settings/general/tls) and\n[DNS settings](http://127.0.0.1:8081/settings/general/dns).\n\n## Local: neither is needed\n\n`*.localhost` resolves to loopback by [RFC 6761](https://www.rfc-editor.org/rfc/rfc6761)\nwith no DNS record and no configuration, and plain HTTP is the right default\nfor local development.\n\nLocal HTTPS is available when you need a secure context: Secure cookies,\nservice workers, WebAuthn.\n\n```bash\nportta tls init\n```\n\nThat issues a local CA and a wildcard certificate for `*.localhost` inside the\ntoolbox container (the host needs no `openssl`), writes them to `config/tls/`,\nwhich is git-ignored, and hands them to Traefik through the file provider.\n\nTrusting the CA writes to your operating system's trust store, so the gateway\n**prints the command and lets you run it** rather than doing it for you:\n\n```bash\nportta tls trust # shows the command for your platform\nportta tls untrust # and how to undo it\n```\n\nFirefox keeps its own store and needs a separate import.\n\n## Remote: a wildcard record and DNS-01\n\nOne wildcard record covers every project and every worktree, forever:\n\n```text\nA *.dev.example.com 203.0.113.10 ; public\nA *.vpn.dev.example.com 100.x.y.z ; private, tailnet address\n```\n\nPointing a **public DNS name at a private tailnet address** is intentional and\nsafe: the name is public, the address is only routable inside your tailnet.\nKeep such a record DNS-only, never proxied.\n\n```bash\nportta dns check # does the wildcard point here?\nportta dns setup # show the record to create\nportta dns setup --apply # create it, via Cloudflare\n```\n\n`dns check` queries a name that can only match the wildcard, so a stray A\nrecord on the apex cannot make a broken wildcard look healthy.\n\n## Two challenges, and which one you want\n\n`ACME_CHALLENGE` picks one. They differ in what they ask of you, not in the\ncertificate a browser ends up trusting.\n\n| | `dns` (default) | `http` |\n|---|---|---|\n| Certificates | one wildcard, `*.example.com` | one per hostname |\n| Needs | a DNS provider credential | `:80` reachable from the internet |\n| Private / VPN-only gateway | works | impossible |\n| A hostname nothing is serving yet | already has HTTPS | gets none until a router exists |\n| First request to a new service | immediate | waits a second or two for issuance |\n| Let's Encrypt limits | one certificate covers everything | each name counts against the weekly limit for the domain |\n\n### `dns`: one wildcard\n\n**HTTP-01 cannot issue a wildcard.** That is why this is the default: Portta\nroutes `<project>-<service>.<domain>`, and a wildcard means every one of those\nnames works over HTTPS the moment it exists.\n\nDNS-01 has a second advantage: the ACME server never needs to reach your host,\nonly to see a TXT record. So a private, VPN-only gateway gets a real,\npublicly-trusted certificate without exposing anything.\n\n```env\nTLS_ENABLED=true\nTLS_MODE=acme\nACME_CHALLENGE=dns\nACME_EMAIL=you@example.com\nACME_DNS_PROVIDER=cloudflare\nACME_DNS_RESOLVERS=1.1.1.1:53,8.8.8.8:53\nCF_DNS_API_TOKEN=... # scoped: Zone:DNS:Edit + Zone:Zone:Read\n```\n\n### `http`: no credential\n\nA public gateway on a public IP can skip the credential entirely. Traefik asks\nfor a certificate the first time a router is created for a hostname, Let's\nEncrypt fetches a token from this host over `:80`, and the certificate arrives.\nThis is what a platform that only ever publishes on public names does, and it\nis why those platforms ask you for nothing but an A record.\n\n```env\nTLS_ENABLED=true\nTLS_MODE=acme\nACME_CHALLENGE=http\nACME_EMAIL=you@example.com\n```\n\n`:80` must be reachable from the internet — `portta public enable`, and nothing\nin front of it that refuses `/.well-known/acme-challenge/`. `portta doctor`\nchecks both the challenge and its one prerequisite.\n\nTraefik terminates TLS at the entrypoint, so a project gets HTTPS without a\nsingle certificate label of its own:\n\n```text\nentryPoints.websecure.http.tls.certResolver = letsencrypt\nentryPoints.websecure.http.tls.domains[0].main = dev.example.com\nentryPoints.websecure.http.tls.domains[0].sans = *.dev.example.com\n```\n\n## Use staging first\n\nLet's Encrypt's rate limits are unforgiving and a misconfigured DNS-01 will\nburn through them quickly.\n\n```env\nACME_CA_SERVER=https://acme-staging-v02.api.letsencrypt.org/directory\n```\n\nCertificates will not be trusted, which is the point: you are testing\nissuance. Switch to production and delete `state/traefik/acme/acme.json` to\nforce a fresh issuance once it works.\n\n## Other providers\n\n`ACME_DNS_PROVIDER` accepts any provider name lego supports (Route 53,\nDigitalOcean, Gandi, deSEC and many more). Cloudflare is the reference\nimplementation because it is common and its scoped tokens are good, not because\nthe gateway depends on it.\n\nFor another provider, set the provider name and pass its credentials to Traefik\nas environment variables, following that provider's lego documentation. The\n`CF_DNS_API_TOKEN` line in `docker/compose/profiles/remote.yaml` is the template.\n\n## The ACME store\n\n`state/traefik/acme/acme.json` holds the account key and every certificate. It\nis created `0600`, git-ignored, and `doctor` fails if the permissions loosen.\nBack it up with `state/` and `.env`; losing it means re-issuing.\n\n## Checking\n\n```bash\nportta tls status\nportta dns check\ndocker logs portta-traefik-1 2>&1 | grep -i acme\n```\n\n## Troubleshooting\n\n**No certificate is issued.** Almost always the DNS credentials. Traefik logs\nthe provider's error. Confirm the token has `Zone:DNS:Edit` on the right zone.\n\n**\"unable to generate a certificate for the domains\".** The resolver could not\nsee the TXT record yet. Check `ACME_DNS_RESOLVERS`, and that the zone is not\nserved by a provider other than the one holding your token.\n\n**Rate limited.** You were on production. Switch to staging, get it working,\nthen switch back.\n\n**The certificate is right but browsers still complain locally.** The local CA\nis not trusted yet. Run `portta tls trust`.\n","text":"Configure DNS and TLS The panel opens these controls directly atTLS settings andDNS settings. Local: neither is needed *.localhost resolves to loopback by RFC 6761with no DNS record and no configuration, and plain HTTP is the right defaultfor local development. Local HTTPS is available when you need a secure context: Secure cookies,service workers, WebAuthn. That issues a local CA and a wildcard certificate for *.localhost inside thetoolbox container (the host needs no openssl), writes them to config/tls/,which is git-ignored, and hands them to Traefik through the file provider. Trusting the CA writes to your operating system's trust store, so the gatewayprints the command and lets you run it rather than doing it for you: Firefox keeps its own store and needs a separate import. Remote: a wildcard record and DNS-01 One wildcard record covers every project and every worktree, forever: Pointing a public DNS name at a private tailnet address is intentional andsafe: the name is public, the address is only routable inside your tailnet.Keep such a record DNS-only, never proxied. dns check queries a name that can only match the wildcard, so a stray Arecord on the apex cannot make a broken wildcard look healthy. Two challenges, and which one you want ACME_CHALLENGE picks one. They differ in what they ask of you, not in thecertificate a browser ends up trusting. dns (default) http Certificates one wildcard, *.example.com one per hostname Needs a DNS provider credential :80 reachable from the internet Private / VPN-only gateway works impossible A hostname nothing is serving yet already has HTTPS gets none until a router exists First request to a new service immediate waits a second or two for issuance Let's Encrypt limits one certificate covers everything each name counts against the weekly limit for the domain dns: one wildcard HTTP-01 cannot issue a wildcard. That is why this is the default: Porttaroutes <project>-<service>.<domain>, and a wildcard means every one of thosenames works over HTTPS the moment it exists. DNS-01 has a second advantage: the ACME server never needs to reach your host,only to see a TXT record. So a private, VPN-only gateway gets a real,publicly-trusted certificate without exposing anything. http: no credential A public gateway on a public IP can skip the credential entirely. Traefik asksfor a certificate the first time a router is created for a hostname, Let'sEncrypt fetches a token from this host over :80, and the certificate arrives.This is what a platform that only ever publishes on public names does, and itis why those platforms ask you for nothing but an A record. :80 must be reachable from the internet — portta public enable, and nothingin front of it that refuses /.well-known/acme-challenge/. portta doctorchecks both the challenge and its one prerequisite. Traefik terminates TLS at the entrypoint, so a project gets HTTPS without asingle certificate label of its own: Use staging first Let's Encrypt's rate limits are unforgiving and a misconfigured DNS-01 willburn through them quickly. Certificates will not be trusted, which is the point: you are testingissuance. Switch to production and delete state/traefik/acme/acme.json toforce a fresh issuance once it works. Other providers ACME_DNS_PROVIDER accepts any provider name lego supports (Route 53,DigitalOcean, Gandi, deSEC and many more). Cloudflare is the referenceimplementation because it is common and its scoped tokens are good, not becausethe gateway depends on it. For another provider, set the provider name and pass its credentials to Traefikas environment variables, following that provider's lego documentation. TheCF_DNS_API_TOKEN line in docker/compose/profiles/remote.yaml is the template. The ACME store state/traefik/acme/acme.json holds the account key and every certificate. Itis created 0600, git-ignored, and doctor fails if the permissions loosen.Back it up with state/ and .env; losing it means re-issuing. Checking Troubleshooting No certificate is issued. Almost always the DNS credentials. Traefik logsthe provider's error. Confirm the token has Zone:DNS:Edit on the right zone. \"unable to generate a certificate for the domains\". The resolver could notsee the TXT record yet. Check ACME_DNS_RESOLVERS, and that the zone is notserved by a provider other than the one holding your token. Rate limited. You were on production. Switch to staging, get it working,then switch back. The certificate is right but browsers still complain locally. The local CAis not trusted yet. Run portta tls trust.","headings":[{"id":"configure-dns-and-tls","text":"Configure DNS and TLS","level":1,"line":0},{"id":"local-neither-is-needed","text":"Local: neither is needed","level":2,"line":6},{"id":"remote-a-wildcard-record-and-dns-01","text":"Remote: a wildcard record and DNS-01","level":2,"line":33},{"id":"two-challenges-and-which-one-you-want","text":"Two challenges, and which one you want","level":2,"line":55},{"id":"dns-one-wildcard","text":"dns: one wildcard","level":3,"line":69},{"id":"http-no-credential","text":"http: no credential","level":3,"line":89},{"id":"use-staging-first","text":"Use staging first","level":2,"line":117},{"id":"other-providers","text":"Other providers","level":2,"line":130},{"id":"the-acme-store","text":"The ACME store","level":2,"line":141},{"id":"checking","text":"Checking","level":2,"line":147},{"id":"troubleshooting","text":"Troubleshooting","level":2,"line":155}],"kind":"markdown"},{"slug":"cloudflare","title":"Configure Cloudflare DNS","description":"Automate DNS records and DNS-01 challenges with scoped credentials.","source":"docs/product/guides/cloudflare.md","audience":"user","section":"Guides","category":"Domains and networking","url":"/docs/cloudflare","markdown":"# Configure Cloudflare DNS\n\nOpen the gateway's [DNS settings](http://127.0.0.1:8081/settings/general/dns) to edit\nthe managed Cloudflare keys from the panel.\n\nCloudflare is the **reference** DNS provider, not a requirement. Everything the\ngateway does works with any provider; Cloudflare just gets automation.\n\n## Token, never the Global API Key\n\nCreate a scoped API Token:\n\n1. Cloudflare dashboard → My Profile → API Tokens → Create Token\n2. Start from *Edit zone DNS*\n3. Permissions: **Zone → DNS → Edit**\n4. Zone Resources: **Include → Specific zone → your zone**\n5. Optionally restrict by client IP and set an expiry\n\n```env\nCLOUDFLARE_ENABLED=true\nCF_DNS_API_TOKEN=...\nCLOUDFLARE_ZONE=example.com\n```\n\nThe Global API Key authenticates **everything in your account**, cannot be\nscoped, and cannot be rotated without breaking every other use. It is never a\nrecommendation here.\n\nThe token is sent only in an `Authorization` header, never on a command line\nwhere `ps` would show it, never printed, and never logged. `portta\ninspect` shows `<set>` or `<unset>`.\n\n## What it is used for\n\n**DNS-01 certificate challenges.** Traefik passes `CF_DNS_API_TOKEN` to lego,\nwhich creates and removes a TXT record during issuance. This is the part that\nmakes wildcard certificates possible.\n\n**Wildcard record management**, through `portta dns`:\n\n```bash\nportta dns status # configuration and current records\nportta dns check # does the wildcard point here?\nportta dns setup # show the change that is needed\nportta dns setup --apply # make it, after confirming\n```\n\n`setup` never writes without `--apply`, and asks before changing an existing\nrecord.\n\n## Keep the record DNS-only\n\nRecords the gateway creates use `proxied: false`, and yours should too.\n\nCloudflare's proxy terminates TLS and re-originates the connection. That breaks\nthe private profile completely, since the record points at a tailnet address\nCloudflare cannot reach, and for the public profile it means Cloudflare, not\nTraefik, decides what your certificate is.\n\nTurn the orange cloud off for `*.dev.example.com`.\n\n## Without Cloudflare\n\nLeave `CLOUDFLARE_ENABLED=false`. `dns setup` then prints the record for you to\ncreate by hand, and `dns check` still verifies it.\n\nFor certificates, set `ACME_DNS_PROVIDER` to your provider's lego name and pass\nits credentials to Traefik as environment variables. The `CF_DNS_API_TOKEN`\nline in `docker/compose/profiles/remote.yaml` is the template to copy.\n\n## Troubleshooting\n\n**\"Cloudflare did not return a zone named ...\"** means the token lacks access\nto that zone, or `CLOUDFLARE_ZONE` is the domain rather than the zone (for\n`dev.example.com` the zone is usually `example.com`).\n\n**Certificates fail while `dns check` passes.** The token needs\n`Zone:DNS:Edit`, not just `Zone:DNS:Read`. Issuance writes a TXT record.\n\n**The record exists but nothing connects.** It is probably proxied. Turn the\norange cloud off.\n","text":"Configure Cloudflare DNS Open the gateway's DNS settings to editthe managed Cloudflare keys from the panel. Cloudflare is the reference DNS provider, not a requirement. Everything thegateway does works with any provider; Cloudflare just gets automation. Token, never the Global API Key Create a scoped API Token: Cloudflare dashboard → My Profile → API Tokens → Create Token Start from Edit zone DNS Permissions: Zone → DNS → Edit Zone Resources: Include → Specific zone → your zone Optionally restrict by client IP and set an expiry The Global API Key authenticates everything in your account, cannot bescoped, and cannot be rotated without breaking every other use. It is never arecommendation here. The token is sent only in an Authorization header, never on a command linewhere ps would show it, never printed, and never logged. portta inspect shows <set> or <unset>. What it is used for DNS-01 certificate challenges. Traefik passes CF_DNS_API_TOKEN to lego,which creates and removes a TXT record during issuance. This is the part thatmakes wildcard certificates possible. Wildcard record management, through portta dns: setup never writes without --apply, and asks before changing an existingrecord. Keep the record DNS-only Records the gateway creates use proxied: false, and yours should too. Cloudflare's proxy terminates TLS and re-originates the connection. That breaksthe private profile completely, since the record points at a tailnet addressCloudflare cannot reach, and for the public profile it means Cloudflare, notTraefik, decides what your certificate is. Turn the orange cloud off for *.dev.example.com. Without Cloudflare Leave CLOUDFLARE_ENABLED=false. dns setup then prints the record for you tocreate by hand, and dns check still verifies it. For certificates, set ACME_DNS_PROVIDER to your provider's lego name and passits credentials to Traefik as environment variables. The CF_DNS_API_TOKENline in docker/compose/profiles/remote.yaml is the template to copy. Troubleshooting \"Cloudflare did not return a zone named ...\" means the token lacks accessto that zone, or CLOUDFLARE_ZONE is the domain rather than the zone (fordev.example.com the zone is usually example.com). Certificates fail while dns check passes. The token needsZone:DNS:Edit, not just Zone:DNS:Read. Issuance writes a TXT record. The record exists but nothing connects. It is probably proxied. Turn theorange cloud off.","headings":[{"id":"configure-cloudflare-dns","text":"Configure Cloudflare DNS","level":1,"line":0},{"id":"token-never-the-global-api-key","text":"Token, never the Global API Key","level":2,"line":8},{"id":"what-it-is-used-for","text":"What it is used for","level":2,"line":32},{"id":"keep-the-record-dns-only","text":"Keep the record DNS-only","level":2,"line":50},{"id":"without-cloudflare","text":"Without Cloudflare","level":2,"line":61},{"id":"troubleshooting","text":"Troubleshooting","level":2,"line":70}],"kind":"markdown"},{"slug":"firewall","title":"Configure firewall rules","description":"Control gateway listeners and understand Docker firewall rules.","source":"docs/product/guides/firewall.md","audience":"user","section":"Guides","category":"Domains and networking","url":"/docs/firewall","markdown":"# Configure firewall rules\n\nThe gateway **never changes a firewall rule**. It tells you what is listening\nand what a rule set should look like; applying it stays your decision.\n\nSettings → Environment → Host security reports every supported firewall it can\nobserve (`ufw`, firewalld, nftables and macOS pf), whether it is active, and a\ndefault inbound policy when the command exposes one. Collection runs on the\nhost through `portta env report`; it never invokes `sudo`. “Could not be\nchecked” therefore means exactly that, not “inactive” or “not installed”.\n\nPortta does not apply any recommendation from the panel. The commands below\nremain operator decisions because an incorrect firewall or SSH change can lock\nyou out of a remote machine.\n\n```bash\nportta network status\n```\n\nshows interfaces, the tailnet address, every published port split by whether\nthe gateway or a consumer project owns it, and the host listeners on the\ngateway's ports.\n\n## Ubuntu with UFW\n\n### Private profile (recommended)\n\nNothing needs to be open. Traefik listens on the tailnet interface only, and\nTailscale itself needs no inbound rule, because it establishes outbound\nconnections and negotiates a path.\n\n```bash\nsudo ufw default deny incoming\nsudo ufw default allow outgoing\nsudo ufw allow OpenSSH # keep this, or lock yourself out\nsudo ufw enable\n```\n\nOptionally allow the Tailscale interface explicitly:\n\n```bash\nsudo ufw allow in on tailscale0\n```\n\n### Public profile\n\n```bash\nsudo ufw allow 80/tcp\nsudo ufw allow 443/tcp\n```\n\nThose two, and nothing more. Never open 5432, 3306, 6379, 27017 or 2375/2376.\n\n## Docker and UFW\n\nWorth knowing: **Docker's published ports bypass UFW.** Docker writes its own\n`iptables` rules in the `DOCKER-USER` chain, ahead of the ones UFW manages. A\n`ports:` entry bound to `0.0.0.0` is reachable even with UFW denying\neverything.\n\nThis is exactly why the gateway treats the bind address as the security\nboundary rather than the firewall:\n\n- the local profile binds `127.0.0.1`;\n- the private profile refuses `0.0.0.0` and, with Tailscale, publishes nothing\n at all;\n- databases and caches are never published, in any profile.\n\nTo filter Docker-published ports, write rules into `DOCKER-USER` directly:\n\n```bash\nsudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 5432 -j DROP\n```\n\nPrefer not publishing the port in the first place.\n\n## Verifying from outside\n\nThe check that matters cannot be run on the host itself. From another machine,\noutside your tailnet:\n\n```bash\nnc -zv vps.example.com 80\nnc -zv vps.example.com 443\nnc -zv vps.example.com 5432 # must fail, in every profile\n```\n\nIn the private profile, all three should fail.\n\n## Cloud provider firewalls\n\nA security group or cloud firewall usually sits in front of the host and is\nevaluated before anything on it. Both have to allow a port for it to be\nreachable, and the cloud layer is a good place to keep 80/443 closed while you\nare on the private profile.\n","text":"Configure firewall rules The gateway never changes a firewall rule. It tells you what is listeningand what a rule set should look like; applying it stays your decision. Settings → Environment → Host security reports every supported firewall it canobserve (ufw, firewalld, nftables and macOS pf), whether it is active, and adefault inbound policy when the command exposes one. Collection runs on thehost through portta env report; it never invokes sudo. “Could not bechecked” therefore means exactly that, not “inactive” or “not installed”. Portta does not apply any recommendation from the panel. The commands belowremain operator decisions because an incorrect firewall or SSH change can lockyou out of a remote machine. shows interfaces, the tailnet address, every published port split by whetherthe gateway or a consumer project owns it, and the host listeners on thegateway's ports. Ubuntu with UFW Private profile (recommended) Nothing needs to be open. Traefik listens on the tailnet interface only, andTailscale itself needs no inbound rule, because it establishes outboundconnections and negotiates a path. Optionally allow the Tailscale interface explicitly: Public profile Those two, and nothing more. Never open 5432, 3306, 6379, 27017 or 2375/2376. Docker and UFW Worth knowing: Docker's published ports bypass UFW. Docker writes its owniptables rules in the DOCKER-USER chain, ahead of the ones UFW manages. Aports: entry bound to 0.0.0.0 is reachable even with UFW denyingeverything. This is exactly why the gateway treats the bind address as the securityboundary rather than the firewall: the local profile binds 127.0.0.1; the private profile refuses 0.0.0.0 and, with Tailscale, publishes nothingat all; databases and caches are never published, in any profile. To filter Docker-published ports, write rules into DOCKER-USER directly: Prefer not publishing the port in the first place. Verifying from outside The check that matters cannot be run on the host itself. From another machine,outside your tailnet: In the private profile, all three should fail. Cloud provider firewalls A security group or cloud firewall usually sits in front of the host and isevaluated before anything on it. Both have to allow a port for it to bereachable, and the cloud layer is a good place to keep 80/443 closed while youare on the private profile.","headings":[{"id":"configure-firewall-rules","text":"Configure firewall rules","level":1,"line":0},{"id":"ubuntu-with-ufw","text":"Ubuntu with UFW","level":2,"line":23},{"id":"private-profile-recommended","text":"Private profile (recommended)","level":3,"line":25},{"id":"public-profile","text":"Public profile","level":3,"line":44},{"id":"docker-and-ufw","text":"Docker and UFW","level":2,"line":53},{"id":"verifying-from-outside","text":"Verifying from outside","level":2,"line":76},{"id":"cloud-provider-firewalls","text":"Cloud provider firewalls","level":2,"line":89}],"kind":"markdown"},{"slug":"remote-development","title":"Choose remote access","description":"Choose private or public access for a remote host.","source":"docs/product/guides/remote-development.md","audience":"user","section":"Guides","category":"Remote access","url":"/docs/remote-development","markdown":"# Choose remote access\n\nThe same gateway, the same commands, one host away. Two modes:\n\n| Profile | Who can reach it | Typical use |\n|---|---|---|\n| `remote-private` | your tailnet only | the default and the recommendation |\n| `remote-public` | the internet | demos, webhooks, external testing (opt-in) |\n\n> **Verification status.** The local profile is exercised end to end by CI on\n> every change. The remote profiles are validated by configuration tests (every\n> profile renders, the private profile never binds `0.0.0.0`), but the tailnet\n> and ACME paths need real credentials and are **not** exercised automatically.\n> Treat the checklist at the end of this page as required, not optional.\n\n## Prerequisites\n\n- Ubuntu 22.04 or 24.04, `amd64` or `arm64`\n- Docker Engine 24+ and the Compose v2 plugin\n- SSH access with a key\n- A Tailscale account for `remote-private`\n- A domain you control for TLS\n\n## Bootstrapping a host\n\nFrom your workstation:\n\n```bash\nportta remote bootstrap deploy@vps.example.com --profile remote-private\n```\n\nIt connects, reports the distribution and architecture, checks Docker, clones\nor updates the repository, creates `.env` **only if absent**, runs `bootstrap`,\nand offers to start the gateway and run `doctor`.\n\nUseful flags: `--dry-run` (change nothing), `--install-docker` (offer to run\nDocker's official installer, asking first, because that is remote code\nexecution as root), `--dir`, `--repo`, `--branch`.\n\nSecrets are never copied from your machine. Set them on the host:\n\n```bash\nssh deploy@vps.example.com\nnano ~/portta/.env # TS_AUTHKEY, ACME_EMAIL, CF_DNS_API_TOKEN\n```\n\nThen drive it from anywhere:\n\n```bash\nportta remote status deploy@vps.example.com\nportta remote doctor deploy@vps.example.com\nportta remote urls deploy@vps.example.com\n```\n\n## Private mode\n\n```env\nPORTTA_PROFILE=remote-private\nTAILSCALE_ENABLED=true\nTS_AUTHKEY=tskey-auth-...\nPRIVATE_DOMAIN=vpn.dev.example.com\nTLS_ENABLED=true\nTLS_MODE=acme\nACME_EMAIL=you@example.com\nCLOUDFLARE_ENABLED=true\nCF_DNS_API_TOKEN=...\nCLOUDFLARE_ZONE=example.com\n```\n\n```bash\n./bin/portta up remote-private\n./bin/portta dns setup --apply\n./bin/portta doctor\n```\n\nTraefik runs **inside the Tailscale container's network namespace**, so it\nlistens on the node's tailnet address and publishes nothing on the VPS's public\ninterface. `remote-private` refuses to bind `0.0.0.0` at all.\n\nDetails and the alternative host-native setup: [Expose the gateway through Tailscale](tailscale.md).\n\nNot using Tailscale? Leave `TAILSCALE_ENABLED=false` and point\n`PORTTA_BIND_ADDRESS` at your VPN interface's address. The profile still\nrefuses `0.0.0.0`.\n\n## Public mode\n\nOff by default. Enabling it is a deliberate act:\n\n```bash\n./bin/portta public enable\n```\n\nIt prints the domain, the interfaces, the ports, the TLS state and the exact\nlist of URLs that would become reachable, then asks. See\n[Enable public access](public-access.md).\n\n## TLS\n\nWildcard certificates require ACME **DNS-01**, because HTTP-01 cannot issue\nthem. That also means a private domain works: the ACME server never has to reach your\nhost, only see the DNS record. See [Configure DNS and TLS](dns-and-tls.md).\n\n## Firewall\n\nThe gateway never changes firewall rules. See [Configure firewall rules](firewall.md) for the\nminimal UFW configuration for each profile.\n\n```bash\n./bin/portta network status\n```\n\nshows interfaces, the tailnet address, every published port and who owns it.\n\n## Updating\n\n```bash\n./bin/portta update\n```\n\nValidates the Compose configuration **before** pulling, pulls the pinned images,\nasks before recreating, and leaves `state/` untouched, including ACME\ncertificates and the Tailscale identity.\n\nTo take new gateway code as well:\n\n```bash\ncd ~/portta && git pull --ff-only && ./bin/portta update\n```\n\n## Backing up\n\nEverything worth keeping is in two places:\n\n```bash\ntar czf portta-backup.tgz .env state/\n```\n\n- `state/traefik/acme/acme.json` holds the issued certificates\n- `state/tailscale/` holds the node identity; losing it means re-authenticating\n- `.env` holds the configuration and secrets\n\nConsumer project data is not here and never was; it belongs to the projects.\n\n## Smoke checklist\n\nBecause the remote paths are not covered by automated tests, verify by hand\nafter the first deploy:\n\n- [ ] `./bin/portta doctor` passes on the host\n- [ ] `./bin/portta network status` shows no unexpected `0.0.0.0` bind\n- [ ] `tailscale status` on the host shows the node connected\n- [ ] the tailnet address is reachable from your workstation\n- [ ] `portta dns check` resolves the wildcard\n- [ ] a demo answers over HTTPS with a valid certificate\n- [ ] from a machine **outside** the tailnet, the VPS's public IP does not answer on 80/443\n- [ ] restarting the gateway leaves applications running\n- [ ] rebooting the host brings the gateway back\n","text":"Choose remote access The same gateway, the same commands, one host away. Two modes: Profile Who can reach it Typical use remote-private your tailnet only the default and the recommendation remote-public the internet demos, webhooks, external testing (opt-in) Verification status. The local profile is exercised end to end by CI onevery change. The remote profiles are validated by configuration tests (everyprofile renders, the private profile never binds 0.0.0.0), but the tailnetand ACME paths need real credentials and are not exercised automatically.Treat the checklist at the end of this page as required, not optional. Prerequisites Ubuntu 22.04 or 24.04, amd64 or arm64 Docker Engine 24+ and the Compose v2 plugin SSH access with a key A Tailscale account for remote-private A domain you control for TLS Bootstrapping a host From your workstation: It connects, reports the distribution and architecture, checks Docker, clonesor updates the repository, creates .env only if absent, runs bootstrap,and offers to start the gateway and run doctor. Useful flags: --dry-run (change nothing), --install-docker (offer to runDocker's official installer, asking first, because that is remote codeexecution as root), --dir, --repo, --branch. Secrets are never copied from your machine. Set them on the host: Then drive it from anywhere: Private mode Traefik runs inside the Tailscale container's network namespace, so itlistens on the node's tailnet address and publishes nothing on the VPS's publicinterface. remote-private refuses to bind 0.0.0.0 at all. Details and the alternative host-native setup: Expose the gateway through Tailscale. Not using Tailscale? Leave TAILSCALE_ENABLED=false and pointPORTTA_BIND_ADDRESS at your VPN interface's address. The profile stillrefuses 0.0.0.0. Public mode Off by default. Enabling it is a deliberate act: It prints the domain, the interfaces, the ports, the TLS state and the exactlist of URLs that would become reachable, then asks. SeeEnable public access. TLS Wildcard certificates require ACME DNS-01, because HTTP-01 cannot issuethem. That also means a private domain works: the ACME server never has to reach yourhost, only see the DNS record. See Configure DNS and TLS. Firewall The gateway never changes firewall rules. See Configure firewall rules for theminimal UFW configuration for each profile. shows interfaces, the tailnet address, every published port and who owns it. Updating Validates the Compose configuration before pulling, pulls the pinned images,asks before recreating, and leaves state/ untouched, including ACMEcertificates and the Tailscale identity. To take new gateway code as well: Backing up Everything worth keeping is in two places: state/traefik/acme/acme.json holds the issued certificates state/tailscale/ holds the node identity; losing it means re-authenticating .env holds the configuration and secrets Consumer project data is not here and never was; it belongs to the projects. Smoke checklist Because the remote paths are not covered by automated tests, verify by handafter the first deploy: [ ] ./bin/portta doctor passes on the host [ ] ./bin/portta network status shows no unexpected 0.0.0.0 bind [ ] tailscale status on the host shows the node connected [ ] the tailnet address is reachable from your workstation [ ] portta dns check resolves the wildcard [ ] a demo answers over HTTPS with a valid certificate [ ] from a machine outside the tailnet, the VPS's public IP does not answer on 80/443 [ ] restarting the gateway leaves applications running [ ] rebooting the host brings the gateway back","headings":[{"id":"choose-remote-access","text":"Choose remote access","level":1,"line":0},{"id":"prerequisites","text":"Prerequisites","level":2,"line":15},{"id":"bootstrapping-a-host","text":"Bootstrapping a host","level":2,"line":23},{"id":"private-mode","text":"Private mode","level":2,"line":54},{"id":"public-mode","text":"Public mode","level":2,"line":85},{"id":"tls","text":"TLS","level":2,"line":97},{"id":"firewall","text":"Firewall","level":2,"line":103},{"id":"updating","text":"Updating","level":2,"line":114},{"id":"backing-up","text":"Backing up","level":2,"line":130},{"id":"smoke-checklist","text":"Smoke checklist","level":2,"line":144}],"kind":"markdown"},{"slug":"remote-bootstrap","title":"Prepare a remote host","description":"Prepare a remote host over SSH.","source":"docs/product/guides/remote-bootstrap.md","audience":"user","section":"Guides","category":"Remote access","url":"/docs/remote-bootstrap","markdown":"# Prepare a remote host\n\n```bash\nportta remote bootstrap deploy@vps.example.com --profile remote-private\n```\n\nPrepares a host to run the gateway. Idempotent: run it again to update.\n\n## What it does\n\n1. Connects over SSH and reports the distribution and architecture.\n2. Checks Docker and Compose. If Docker is missing it stops, unless\n `--install-docker` is given.\n3. Clones the repository, or fast-forwards an existing checkout.\n4. Creates `.env` from the example **only if absent**, and sets the profile.\n5. Runs `bootstrap` on the host.\n6. Offers to start the gateway, then runs `doctor` and `urls`.\n\n## Flags\n\n| | |\n|---|---|\n| `--profile <name>` | Profile to configure (default `remote-private`) |\n| `--dir <path>` | Install location (default `~/portta`) |\n| `--repo <url>` | Repository (default: this checkout's `origin`) |\n| `--branch <name>` | Branch (default `main`) |\n| `--install-docker` | Offer to install Docker when missing |\n| `--dry-run` | Print what would happen, change nothing |\n\n## Rules it keeps\n\n**An existing `.env` is never overwritten.** It holds the host's secrets and\nits configuration. If it is there, it is left exactly as it was.\n\n**No secrets are transferred.** `TS_AUTHKEY`, `ACME_EMAIL` and\n`CF_DNS_API_TOKEN` are set on the host, by you:\n\n```bash\nssh deploy@vps.example.com 'nano ~/portta/.env'\n```\n\n**Host key verification stays on.** SSH runs with\n`StrictHostKeyChecking=accept-new`: a new host's key is recorded on first\nconnection, but a *changed* key is still refused, which is the case worth\ndefending against. It is never set to `no`. Override deliberately with\n`PORTTA_SSH_HOST_KEY_POLICY=yes` if your workflow pre-populates `known_hosts`.\n\n**`curl | sh` is never silent.** With `--install-docker`, the gateway says\nplainly that it is about to run Docker's official installation script as root\non the remote host, and asks. Without the flag it stops and points at the\nmanual steps below.\n\n## Installing Docker by hand\n\nPreferred over the convenience script for anything long-lived:\n\n```bash\nsudo apt-get update\nsudo apt-get install -y ca-certificates curl\nsudo install -m 0755 -d /etc/apt/keyrings\nsudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \\\n -o /etc/apt/keyrings/docker.asc\nsudo chmod a+r /etc/apt/keyrings/docker.asc\n\necho \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \\\n https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable\" \\\n | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null\n\nsudo apt-get update\nsudo apt-get install -y docker-ce docker-ce-cli containerd.io \\\n docker-buildx-plugin docker-compose-plugin\n\nsudo usermod -aG docker \"$USER\" # log out and back in\n```\n\nNote that adding a user to the `docker` group is equivalent to giving them\nroot, because the Docker API is not namespaced. Use a dedicated deployment\nuser.\n\n## Over Tailscale SSH\n\nWith Tailscale SSH configured, the same commands work, with the target being\nthe tailnet name:\n\n```bash\nportta remote bootstrap deploy@dev-vps --profile remote-private\n```\n\nAuthentication and audit then come from your tailnet policy rather than from\n`authorized_keys`.\n\n## Driving the host afterwards\n\n```bash\nportta remote status deploy@vps.example.com\nportta remote doctor deploy@vps.example.com\nportta remote urls deploy@vps.example.com\nportta remote exec deploy@vps.example.com -- docker ps\n```\n\n## Troubleshooting\n\n**Cannot connect.** Check the host, your key, and whether the host key is\naccepted. `ssh -v deploy@host` says more than the wrapper does.\n\n**\"docker: command not found\" although it is installed.** A non-interactive SSH\nsession uses a shorter `PATH`. Check `ssh host 'command -v docker'`.\n\n**Permission denied talking to the Docker daemon.** The user is not in the\n`docker` group, or the session predates being added. Log out and back in.\n","text":"Prepare a remote host Prepares a host to run the gateway. Idempotent: run it again to update. What it does Connects over SSH and reports the distribution and architecture. Checks Docker and Compose. If Docker is missing it stops, unless--install-docker is given. Clones the repository, or fast-forwards an existing checkout. Creates .env from the example only if absent, and sets the profile. Runs bootstrap on the host. Offers to start the gateway, then runs doctor and urls. Flags --profile <name> Profile to configure (default remote-private) --dir <path> Install location (default ~/portta) --repo <url> Repository (default: this checkout's origin) --branch <name> Branch (default main) --install-docker Offer to install Docker when missing --dry-run Print what would happen, change nothing Rules it keeps An existing .env is never overwritten. It holds the host's secrets andits configuration. If it is there, it is left exactly as it was. No secrets are transferred. TS_AUTHKEY, ACME_EMAIL andCF_DNS_API_TOKEN are set on the host, by you: Host key verification stays on. SSH runs withStrictHostKeyChecking=accept-new: a new host's key is recorded on firstconnection, but a changed key is still refused, which is the case worthdefending against. It is never set to no. Override deliberately withPORTTA_SSH_HOST_KEY_POLICY=yes if your workflow pre-populates known_hosts. curl | sh is never silent. With --install-docker, the gateway saysplainly that it is about to run Docker's official installation script as rooton the remote host, and asks. Without the flag it stops and points at themanual steps below. Installing Docker by hand Preferred over the convenience script for anything long-lived: Note that adding a user to the docker group is equivalent to giving themroot, because the Docker API is not namespaced. Use a dedicated deploymentuser. Over Tailscale SSH With Tailscale SSH configured, the same commands work, with the target beingthe tailnet name: Authentication and audit then come from your tailnet policy rather than fromauthorized_keys. Driving the host afterwards Troubleshooting Cannot connect. Check the host, your key, and whether the host key isaccepted. ssh -v deploy@host says more than the wrapper does. \"docker: command not found\" although it is installed. A non-interactive SSHsession uses a shorter PATH. Check ssh host 'command -v docker'. Permission denied talking to the Docker daemon. The user is not in thedocker group, or the session predates being added. Log out and back in.","headings":[{"id":"prepare-a-remote-host","text":"Prepare a remote host","level":1,"line":0},{"id":"what-it-does","text":"What it does","level":2,"line":8},{"id":"flags","text":"Flags","level":2,"line":18},{"id":"rules-it-keeps","text":"Rules it keeps","level":2,"line":29},{"id":"installing-docker-by-hand","text":"Installing Docker by hand","level":2,"line":52},{"id":"over-tailscale-ssh","text":"Over Tailscale SSH","level":2,"line":79},{"id":"driving-the-host-afterwards","text":"Driving the host afterwards","level":2,"line":91},{"id":"troubleshooting","text":"Troubleshooting","level":2,"line":100}],"kind":"markdown"},{"slug":"ssh-keys","title":"SSH keys owned by Portta","description":"Generate, import, test and remove instance-owned SSH credentials.","source":"docs/product/guides/ssh-keys.md","audience":"user","section":"Guides","category":"Remote access","url":"/docs/ssh-keys","markdown":"# SSH keys owned by Portta\n\nOpen **Settings → Environment → SSH keys** to generate, import, inspect, test or\nremove credentials owned by this Portta installation.\n\n## Generate or import\n\nGenerated keys use ED25519 by default. RSA is available at 4096 bits for a\nservice that requires it. An import accepts an unencrypted ED25519 or RSA\nprivate key and derives the public key locally. The private value is write-only:\nit is not returned by the API and the panel cannot display it later.\n\nCopy the public key from the expanded row and add it to the GitHub, GitLab or\nBitbucket account that should accept it. **Test** performs only an SSH\nauthentication greeting against the selected forge; it does not clone, fetch\nor push a repository. The greeting uses `StrictHostKeyChecking=yes` against\nhost keys Portta pins from each forge's published catalogue, so the first\nconnection cannot accept an unexpected host key. GitHub's successful greeting\nexits with status 1, which Portta recognises as success.\n\n## Storage and permissions\n\nPrivate keys live in `state/ssh/<id>` with mode `0600`; public keys use\n`state/ssh/<id>.pub` with mode `0644`. The directory uses `0700`. The database\ncontains public material and metadata only. Portta does not encrypt private\nkeys at rest, so host disk encryption and backup policy remain the operator's\nresponsibility.\n\nViewers and developers may list and copy public keys. Only owners and\nadministrators may generate, import, test or remove them. Audit entries for\nthose mutations contain the name and SHA256 fingerprint, never private key\nmaterial.\n\n`portta env report` includes the directory mode in the host-security report.\nIf it warns, run `portta repair --apply` or set it directly:\n\n```bash\nchmod 700 \"$PORTTA_HOME/state/ssh\"\n```\n\nRemoving a key permanently removes both files and its metadata. Remove its\npublic key from each forge separately if it should no longer authenticate.\n","text":"SSH keys owned by Portta Open Settings → Environment → SSH keys to generate, import, inspect, test orremove credentials owned by this Portta installation. Generate or import Generated keys use ED25519 by default. RSA is available at 4096 bits for aservice that requires it. An import accepts an unencrypted ED25519 or RSAprivate key and derives the public key locally. The private value is write-only:it is not returned by the API and the panel cannot display it later. Copy the public key from the expanded row and add it to the GitHub, GitLab orBitbucket account that should accept it. Test performs only an SSHauthentication greeting against the selected forge; it does not clone, fetchor push a repository. The greeting uses StrictHostKeyChecking=yes againsthost keys Portta pins from each forge's published catalogue, so the firstconnection cannot accept an unexpected host key. GitHub's successful greetingexits with status 1, which Portta recognises as success. Storage and permissions Private keys live in state/ssh/<id> with mode 0600; public keys usestate/ssh/<id>.pub with mode 0644. The directory uses 0700. The databasecontains public material and metadata only. Portta does not encrypt privatekeys at rest, so host disk encryption and backup policy remain the operator'sresponsibility. Viewers and developers may list and copy public keys. Only owners andadministrators may generate, import, test or remove them. Audit entries forthose mutations contain the name and SHA256 fingerprint, never private keymaterial. portta env report includes the directory mode in the host-security report.If it warns, run portta repair --apply or set it directly: Removing a key permanently removes both files and its metadata. Remove itspublic key from each forge separately if it should no longer authenticate.","headings":[{"id":"ssh-keys-owned-by-portta","text":"SSH keys owned by Portta","level":1,"line":0},{"id":"generate-or-import","text":"Generate or import","level":2,"line":5},{"id":"storage-and-permissions","text":"Storage and permissions","level":2,"line":20}],"kind":"markdown"},{"slug":"tailscale","title":"Expose the gateway through Tailscale","description":"Attach the gateway to your private tailnet.","source":"docs/product/guides/tailscale.md","audience":"user","section":"Guides","category":"Remote access","url":"/docs/tailscale","markdown":"# Expose the gateway through Tailscale\n\nOpen the gateway's [Project access settings](http://127.0.0.1:8081/settings/general/project-access) to edit\nthe managed Tailscale keys from the panel.\n\nTailscale is how the gateway becomes reachable without becoming public. The\nVPS keeps 80 and 443 closed to the internet, and the gateway answers on the\nnode's tailnet address.\n\n## How it is wired\n\n`docker/compose/attach/tailscale.yaml` runs one Tailscale container and puts Traefik\n**inside its network namespace**:\n\n```yaml\nservices:\n tailscale:\n image: tailscale/tailscale:v1.102.3\n networks: [gateway, control]\n # ...\n\n traefik:\n network_mode: service:tailscale\n```\n\nSharing a namespace means Traefik listens directly on the tailnet interface.\nNothing is published on the host's public interface, so there is no firewall\nrule to get wrong. Traefik keeps its access to the shared and control networks\nbecause the Tailscale container joins them and the two share a namespace.\n\n```mermaid\nflowchart LR\n mac([your Mac]) -->|tailnet| ts\n subgraph ns [shared network namespace]\n ts[tailscale<br/>100.x.y.z]\n tr[traefik :80 :443]\n end\n ts --- tr\n tr --> apps[project HTTP services<br/>on the portta network]\n internet([public internet]) -.->|no listener| vps[VPS public IP]\n```\n\nSee [ADR 0007](../../development/adr/0007-tailscale-sidecar.md).\n\n## Settings that are not optional\n\n**`TS_USERSPACE=false`.** Userspace mode has no real interface in the\nnamespace, so an inbound tailnet connection would never reach Traefik. Kernel\nnetworking is what makes the pattern work, hence `/dev/net/tun` and\n`NET_ADMIN`.\n\n**`TS_ACCEPT_DNS=false`.** MagicDNS rewrites `/etc/resolv.conf`. Traefik shares\nthis namespace and must keep resolving container names through Docker's\nresolver, or discovery breaks in a way that is genuinely confusing to debug.\n\n**`TS_AUTH_ONCE=true`** with a persisted `TS_STATE_DIR`. Without it, every\nrestart re-runs `tailscale up` and burns a non-reusable key.\n\n**`state/tailscale/` persisted.** It holds the node identity. Lose it and the\nnode re-registers with a new name and address, and your DNS records point at\nnothing.\n\n## Auth keys\n\nGenerate an **ephemeral, pre-authorized, tagged** key in the admin console:\n\n```env\nTS_AUTHKEY=tskey-auth-...\nTS_EXTRA_ARGS=--advertise-tags=tag:portta\n```\n\nEphemeral so a leaked key ages out. Tagged so ACLs can name the node without\ndepending on a person's identity. Pre-authorized so the container does not sit\nwaiting for manual approval.\n\nThe key is only needed for the first login; after that the persisted state is\nenough. It never enters Git: `.env` is ignored and lint fails on a tracked\n`tskey-`.\n\n## ACLs\n\nDeny by default, then grant deliberately. A minimal policy:\n\n```jsonc\n{\n \"tagOwners\": {\n \"tag:portta\": [\"autogroup:admin\"]\n },\n \"acls\": [\n {\n // developers reach the gateway's HTTP ports, and nothing else\n \"action\": \"accept\",\n \"src\": [\"group:developers\"],\n \"dst\": [\"tag:portta:80,443\"]\n }\n ]\n}\n```\n\nNote what is absent: no `*` in `src` or `dst`, and no database ports. Database\naccess has its own path, a bridge or a tunnel, rather than a standing ACL.\n\nThe gateway never edits your tailnet policy. This is a snippet to adapt.\n\n## Container or host-native?\n\nThe container is the default: it is self-contained, versioned with the rest of\nthe stack, and leaves no daemon on the host.\n\nInstall Tailscale **on the host** instead when you need something outside the\nnamespace: subnet routing to the VPS's other networks, Tailscale SSH to the\nhost itself, exit-node behaviour, or a firewall integration. Then:\n\n```env\nTAILSCALE_ENABLED=false\nPORTTA_BIND_ADDRESS=100.x.y.z # the host's tailnet address\n```\n\nThe gateway attaches to the host instead and binds only that address.\n`remote-private` still refuses `0.0.0.0`.\n\n## Checking it\n\n```bash\n./bin/portta network status # tailnet address and every bind\n./bin/portta doctor # namespace sharing, auth, persisted state\ndocker exec portta-tailscale-1 tailscale status\n```\n\n`doctor` fails if Traefik is not actually in the Tailscale namespace, a case\nwhere everything looks fine but nothing is reachable.\n\n## Troubleshooting\n\n**No tailnet address.** The key is expired, already used (with\n`TS_AUTH_ONCE=true` and no persisted state), or the device needs approval.\nCheck `docker logs portta-tailscale-1`.\n\n**Reachable on the tailnet but every route 404s.** Traefik is up but discovery\nis broken, usually MagicDNS overwriting `resolv.conf`. Confirm\n`TS_ACCEPT_DNS=false`.\n\n**Works, then stops after a restart.** `state/tailscale/` is not persisted, so\nthe node re-registered under a new address.\n\n**`/dev/net/tun` missing.** Some hosts do not expose it. Load the module, or\nfall back to host-native Tailscale.\n","text":"Expose the gateway through Tailscale Open the gateway's Project access settings to editthe managed Tailscale keys from the panel. Tailscale is how the gateway becomes reachable without becoming public. TheVPS keeps 80 and 443 closed to the internet, and the gateway answers on thenode's tailnet address. How it is wired docker/compose/attach/tailscale.yaml runs one Tailscale container and puts Traefikinside its network namespace: Sharing a namespace means Traefik listens directly on the tailnet interface.Nothing is published on the host's public interface, so there is no firewallrule to get wrong. Traefik keeps its access to the shared and control networksbecause the Tailscale container joins them and the two share a namespace. See ADR 0007. Settings that are not optional TS_USERSPACE=false. Userspace mode has no real interface in thenamespace, so an inbound tailnet connection would never reach Traefik. Kernelnetworking is what makes the pattern work, hence /dev/net/tun andNET_ADMIN. TS_ACCEPT_DNS=false. MagicDNS rewrites /etc/resolv.conf. Traefik sharesthis namespace and must keep resolving container names through Docker'sresolver, or discovery breaks in a way that is genuinely confusing to debug. TS_AUTH_ONCE=true with a persisted TS_STATE_DIR. Without it, everyrestart re-runs tailscale up and burns a non-reusable key. state/tailscale/ persisted. It holds the node identity. Lose it and thenode re-registers with a new name and address, and your DNS records point atnothing. Auth keys Generate an ephemeral, pre-authorized, tagged key in the admin console: Ephemeral so a leaked key ages out. Tagged so ACLs can name the node withoutdepending on a person's identity. Pre-authorized so the container does not sitwaiting for manual approval. The key is only needed for the first login; after that the persisted state isenough. It never enters Git: .env is ignored and lint fails on a trackedtskey-. ACLs Deny by default, then grant deliberately. A minimal policy: Note what is absent: no * in src or dst, and no database ports. Databaseaccess has its own path, a bridge or a tunnel, rather than a standing ACL. The gateway never edits your tailnet policy. This is a snippet to adapt. Container or host-native? The container is the default: it is self-contained, versioned with the rest ofthe stack, and leaves no daemon on the host. Install Tailscale on the host instead when you need something outside thenamespace: subnet routing to the VPS's other networks, Tailscale SSH to thehost itself, exit-node behaviour, or a firewall integration. Then: The gateway attaches to the host instead and binds only that address.remote-private still refuses 0.0.0.0. Checking it doctor fails if Traefik is not actually in the Tailscale namespace, a casewhere everything looks fine but nothing is reachable. Troubleshooting No tailnet address. The key is expired, already used (withTS_AUTH_ONCE=true and no persisted state), or the device needs approval.Check docker logs portta-tailscale-1. Reachable on the tailnet but every route 404s. Traefik is up but discoveryis broken, usually MagicDNS overwriting resolv.conf. ConfirmTS_ACCEPT_DNS=false. Works, then stops after a restart. state/tailscale/ is not persisted, sothe node re-registered under a new address. /dev/net/tun missing. Some hosts do not expose it. Load the module, orfall back to host-native Tailscale.","headings":[{"id":"expose-the-gateway-through-tailscale","text":"Expose the gateway through Tailscale","level":1,"line":0},{"id":"how-it-is-wired","text":"How it is wired","level":2,"line":9},{"id":"settings-that-are-not-optional","text":"Settings that are not optional","level":2,"line":44},{"id":"auth-keys","text":"Auth keys","level":2,"line":62},{"id":"acls","text":"ACLs","level":2,"line":79},{"id":"container-or-host-native","text":"Container or host-native?","level":2,"line":104},{"id":"checking-it","text":"Checking it","level":2,"line":121},{"id":"troubleshooting","text":"Troubleshooting","level":2,"line":132}],"kind":"markdown"},{"slug":"tailscale-services","title":"Configure persistent Tailscale services","description":"Give selected services stable private addresses.","source":"docs/product/guides/tailscale-services.md","audience":"user","section":"Guides","category":"Remote access","url":"/docs/tailscale-services","markdown":"# Configure persistent Tailscale services\n\nA persistent, private address for a TCP service, so the database you use every\nday keeps port 5432 and a stable name instead of a fresh loopback port each\nsession.\n\n> **Verification status.** The forwarder side is implemented and tested: the\n> container is created, joins the right two networks, and is reachable by its\n> alias on the standard port (`tests/e2e/tcp-access.test.sh`). The tailnet side\n> (the Service advertisement and the grants) is **configuration you apply on\n> your own tailnet**, and is not exercised by any automated test here. The\n> gateway prints exactly what to apply and never edits your Tailscale policy.\n\n## The architecture, and why it is shaped this way\n\n```text\nproject-a_default project-b_default\n postgres postgres\n | |\n forwarder-a-db forwarder-b-db\n | |\n +------- portta-access ----+\n |\n Tailscale\n```\n\nEach published service gets **its own forwarder**, joined to exactly two\nnetworks: that project's private network, and the gateway's access network.\n\nThe alternative, attaching the Tailscale container directly to every project's\nprivate network, would be simpler and much worse. It would give one container\na route into every project at once, and it would put projects one\nmisconfiguration away from resolving each other's service names. The forwarder\nper service keeps every project network isolated from every other, and\nTailscale only ever sees the access network.\n\n`portta doctor` fails if a forwarder ends up on the shared HTTP network.\n\n## Publishing one\n\n```bash\nportta service publish --private \\\n --project base-empresarial --service postgres\n```\n\n```text\n alias base-empresarial-postgres\n target postgres:5432\n project network base-empresarial_default\n access network portta-access\n reachable at base-empresarial-postgres:5432 (from the access network)\n```\n\n```bash\nportta service list\nportta service unpublish base-empresarial-postgres\nportta service unpublish --project base-empresarial\n```\n\nUnpublishing removes the forwarder. The database keeps running; it was never\ntouched.\n\n## Wiring it to the tailnet\n\nThree steps, all on your side.\n\n**1. Let Tailscale reach the access network.**\n\n```bash\ndocker network connect portta-access portta-tailscale-1\n```\n\nThis is the only network the Tailscale container joins beyond the gateway's\nown. It never joins a project network.\n\n**2. Advertise a Tailscale Service.** Each service gets its own virtual address,\nwhich is what lets several databases keep port 5432 and still be told apart:\n\n```text\nsvc:base-empresarial-postgres -> tcp:5432 -> base-empresarial-postgres:5432\nsvc:base-eleicoes-postgres -> tcp:5432 -> base-eleicoes-postgres:5432\nsvc:base-empresarial-redis -> tcp:6379 -> base-empresarial-redis:6379\n```\n\nDistinct identities, standard ports, no tunnels.\n\n**3. Grant access, never a blanket rule.**\n\n```jsonc\n{\n \"grants\": [\n {\n \"src\": [\"group:developers\"],\n \"dst\": [\"svc:base-empresarial-postgres\"],\n \"ip\": [\"tcp:5432\"]\n },\n {\n \"src\": [\"group:developers\"],\n \"dst\": [\"svc:base-empresarial-redis\"],\n \"ip\": [\"tcp:6379\"]\n }\n ]\n}\n```\n\nDeny by default, then name the source, the destination and the port. Note what\nis not there: no `*` in `src`, no `*` in `dst`, and no rule that would reach a\nservice nobody asked to publish.\n\nCheck the current [Tailscale Services documentation](https://tailscale.com/docs/features/tailscale-services)\nfor the exact syntax your tailnet expects. This is the model, not a\ncopy-paste-and-forget snippet, and the gateway will not apply it for you.\n\n## When to use this instead of a bridge\n\n| | `access open` | `service publish --private` |\n|---|---|---|\n| Lifetime | a session | until you unpublish |\n| Address | `127.0.0.1:<random>` | a stable tailnet name |\n| Port | assigned | the standard one |\n| Setup | none | Tailscale Service and a grant |\n| Good for | debugging, a one-off query, a GUI for an hour | the database you open every morning |\n\nStart with `access open`. Publish the two or three you keep reaching for.\n\n## Not set up for this yet?\n\n`portta remote access open` works today over plain SSH and needs no\nTailscale Services configuration at all. See\n[Open remote tunnels](remote-tunnels.md).\n","text":"Configure persistent Tailscale services A persistent, private address for a TCP service, so the database you use everyday keeps port 5432 and a stable name instead of a fresh loopback port eachsession. Verification status. The forwarder side is implemented and tested: thecontainer is created, joins the right two networks, and is reachable by itsalias on the standard port (tests/e2e/tcp-access.test.sh). The tailnet side(the Service advertisement and the grants) is configuration you apply onyour own tailnet, and is not exercised by any automated test here. Thegateway prints exactly what to apply and never edits your Tailscale policy. The architecture, and why it is shaped this way Each published service gets its own forwarder, joined to exactly twonetworks: that project's private network, and the gateway's access network. The alternative, attaching the Tailscale container directly to every project'sprivate network, would be simpler and much worse. It would give one containera route into every project at once, and it would put projects onemisconfiguration away from resolving each other's service names. The forwarderper service keeps every project network isolated from every other, andTailscale only ever sees the access network. portta doctor fails if a forwarder ends up on the shared HTTP network. Publishing one Unpublishing removes the forwarder. The database keeps running; it was nevertouched. Wiring it to the tailnet Three steps, all on your side. 1. Let Tailscale reach the access network. This is the only network the Tailscale container joins beyond the gateway'sown. It never joins a project network. 2. Advertise a Tailscale Service. Each service gets its own virtual address,which is what lets several databases keep port 5432 and still be told apart: Distinct identities, standard ports, no tunnels. 3. Grant access, never a blanket rule. Deny by default, then name the source, the destination and the port. Note whatis not there: no * in src, no * in dst, and no rule that would reach aservice nobody asked to publish. Check the current Tailscale Services documentationfor the exact syntax your tailnet expects. This is the model, not acopy-paste-and-forget snippet, and the gateway will not apply it for you. When to use this instead of a bridge access open service publish --private Lifetime a session until you unpublish Address 127.0.0.1:<random> a stable tailnet name Port assigned the standard one Setup none Tailscale Service and a grant Good for debugging, a one-off query, a GUI for an hour the database you open every morning Start with access open. Publish the two or three you keep reaching for. Not set up for this yet? portta remote access open works today over plain SSH and needs noTailscale Services configuration at all. SeeOpen remote tunnels.","headings":[{"id":"configure-persistent-tailscale-services","text":"Configure persistent Tailscale services","level":1,"line":0},{"id":"the-architecture-and-why-it-is-shaped-this-way","text":"The architecture, and why it is shaped this way","level":2,"line":13},{"id":"publishing-one","text":"Publishing one","level":2,"line":38},{"id":"wiring-it-to-the-tailnet","text":"Wiring it to the tailnet","level":2,"line":62},{"id":"when-to-use-this-instead-of-a-bridge","text":"When to use this instead of a bridge","level":2,"line":113},{"id":"not-set-up-for-this-yet","text":"Not set up for this yet?","level":2,"line":125}],"kind":"markdown"},{"slug":"cloudflare-tunnel","title":"Publish through Cloudflare Tunnel","description":"Publish HTTPS routes through a Cloudflare Tunnel.","source":"docs/product/guides/cloudflare-tunnel.md","audience":"user","section":"Guides","category":"Remote access","url":"/docs/cloudflare-tunnel","markdown":"# Publish through Cloudflare Tunnel\n\nPublish your services over HTTPS **without opening a port**, from a machine that\nhas no public address at all.\n\n```text\nyour laptop / home lab / VPS the internet\n |\n container <- Traefik <- cloudflared -> Cloudflare edge -> visitor\n |\n outbound only. No inbound port, ever.\n```\n\nThis is optional. Portta works without it, and enabling it changes nothing about\nwhich services are reachable — that stays a separate, per-service decision.\n\n## Why you might want it\n\n| You have | Without a tunnel | With a tunnel |\n|---|---|---|\n| a laptop | `*.localhost`, and nothing anyone else can open | a real HTTPS URL you can send someone |\n| a home lab behind NAT | port forwarding on the router, if it is even possible | nothing to forward |\n| a connection behind CGNAT | **impossible**: there is no address to forward to | works |\n| a VPS with a public address | ports 80 and 443 open to the world | ports stay closed |\n| no domain | `sslip.io` over plain HTTP | HTTPS on your own domain |\n\nThe one that is not a trade-off: **behind CGNAT there is no alternative.** Your\nconnection has no public address to point a DNS record at.\n\n## Tunnel, Proxy and Access are three different things\n\nThey are often confused, and they solve different problems. You can use any\ncombination.\n\n**Cloudflare Proxy** (the orange cloud) sits in front of a server that already\nhas a public address. Traffic goes to Cloudflare, then to your address. You still\nneed an open port.\n\n**Cloudflare Tunnel** replaces the address entirely. `cloudflared` runs on your\nmachine and dials *out* to Cloudflare, and traffic comes back down that\nconnection. Nothing needs to reach you.\n\n**Cloudflare Access** is authentication at the edge. It decides *who* may reach\na hostname, before the request ever gets to you. It is not a way in; it is a lock\non a door that already exists.\n\nPortta treats them exactly that way: the tunnel is transport, Access is an\noptional edge policy, and Portta ForwardAuth remains an independent origin-side\nlogin. Neither mechanism depends on the other.\n\n## What you need\n\n- A domain on Cloudflare. Any plan, including the free one.\n- Docker, which you already have.\n\nThat is all. You do **not** need: a public IP address, an open port, a static\naddress, a certificate, or a Cloudflare API token.\n\n## Setting it up\n\n### 1. Create the tunnel\n\nGo to [one.dash.cloudflare.com](https://one.dash.cloudflare.com) → **Networks** →\n**Tunnels** → **Create a tunnel** → **Cloudflared**.\n\nGive it a name (`portta` is a fine one) and select **Create**.\n\n### 2. Copy the token\n\nThe next screen shows an install command for your platform. It looks like:\n\n```bash\ndocker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token eyJhIjoiNWFiNGU5Z...\n```\n\n**Do not run it.** Copy only the long `eyJ...` string at the end — that is the\ntoken. It is the whole credential, so treat it like a password.\n\nYou can skip the dashboard's \"public hostname\" step entirely. Portta configures\nrouting itself, which is what lets one tunnel serve every project you will ever\ncreate.\n\n### 3. Give it to Portta\n\nFrom the panel: **Settings → Cloudflare Tunnel**, paste the domain and the token,\nand select **Connect**.\n\nOr from the command line, where the token is read from a file or a hidden prompt\nrather than an argument:\n\n```bash\nportta tunnel setup --zone example.com --token-file ./token.txt\nportta tunnel setup --zone example.com # prompts, input hidden\n```\n\nThere is deliberately no `--token` flag. An argument is visible in `ps` to every\nuser on the machine, and in your shell history.\n\n### 4. Point the domain at the tunnel\n\n`setup` prints the one record you need:\n\n```text\n Type CNAME\n Name *.example.com\n Target 6ff42ae2-765d-4adf-8112-31c55c1551ef.cfargotunnel.com\n Proxy on (orange cloud)\n```\n\nCreate it in **DNS → Records**. This is the only manual Cloudflare step, and it\nis done **once**: the wildcard covers every project you create afterwards.\n\nCloudflare proxies wildcard records on every plan, and Universal SSL covers\n`*.example.com` for free.\n\n> The tunnel UUID in that target is not a secret. `cfargotunnel.com` only accepts\n> records from the account that owns the tunnel.\n\n### 5. Start it\n\n```bash\nportta tunnel enable # from the panel: Enable\nportta tunnel test # confirms traffic is flowing\n```\n\n`test` asks the internet for a hostname nothing is routed to. Traefik answering\n`404` is success: it proves the whole path — edge, connector, proxy — works.\n\n## Exposing a service\n\nOnce the tunnel is connected, every project gets a hostname automatically:\n\n```text\nweb--ecommerce.example.com\napi--ecommerce.example.com\nmail--ecommerce.example.com\n```\n\nand, where a branch or pull request needs its own:\n\n```text\nweb--ecommerce--develop.example.com\nweb--ecommerce--pr-123.example.com\n```\n\n**Nothing needs to be created at Cloudflare for any of these.** Starting a\ncontainer is the whole operation. The connector carries one wildcard rule, and\nTraefik routes by hostname exactly as it always has.\n\nEverything sits in a single DNS label on purpose. `*.example.com` covers one\nlevel, and so does the free certificate; `web.ecommerce.example.com` would need\nCloudflare's paid Advanced Certificate Manager. See\n[ADR 0023](../../development/adr/0023-flat-hostname-labels.md).\n\n## Turning it off\n\nFrom the panel, **Disable**; or:\n\n```bash\nportta tunnel disable # stops the connector, keeps the configuration\nportta tunnel disable --forget # also deletes the local credential\n```\n\nEither way, **nothing in your Cloudflare account is touched**. The tunnel, the\nDNS record and any Access policy stay exactly as they are, and Portta will never\ndelete them for you. Re-enabling after a plain `disable` needs no token.\n\n## Token security\n\nThe token is the credential. Anyone who has it can run your tunnel.\n\nWhat Portta does with it:\n\n- decodes it once into `state/cloudflared/credentials.json`, mode `0600`, in a\n directory that is mode `0700`\n- **never** writes it to `.env`, which is the file the panel edits and you read\n- **never** returns it from any API endpoint, in any state\n- **never** puts it in a log line, a diagnostic, or an error message\n- **never** passes it as a command-line argument, where `ps` would show it\n\nThe panel reports `Configured ✓` and offers **Replace token**. It cannot show you\nthe value again, and neither can the CLI. If you lose it, create a new token in\nthe dashboard — the old one keeps working until you rotate it there.\n\nPortta holds no Cloudflare API token and cannot change your account.\n\n## Troubleshooting\n\nEach failure has a distinct signature, which is why the panel names them\nseparately rather than saying \"not working\".\n\n**`530` from Cloudflare, \"Tunnel error\".** Cloudflare has no connector for this\ntunnel. The connector is not running, or cannot reach the edge.\n\n```bash\nportta tunnel status\nportta tunnel logs\n```\n\n**`502` from Cloudflare.** The connector is connected but could not reach your\nservice. Traefik is down, or the container is stopped.\n\n```bash\nportta status\n```\n\n**Traefik's own `404`.** The tunnel works perfectly and nothing is routed at that\nhostname. Check the service has `traefik.enable=true` and is on the shared\nnetwork.\n\n**Panel says \"Authentication error\".** Cloudflare rejected the token. The tunnel\nwas probably deleted, or the token belongs to a different account. Create a new\ntunnel and paste the new token.\n\n**The connector will not start.** Check that outbound traffic is allowed: it\ndials Cloudflare on `7844/udp` and falls back to `443/tcp`. A firewall that\nblocks outbound UDP is the usual cause.\n\n**The hostname does not resolve at all.**\n\n```bash\ndig +short web--ecommerce.example.com\n```\n\nNothing back means the wildcard CNAME is missing, or is not proxied. It must be\nthe orange cloud.\n\n**It worked and then stopped after a reboot.** The connector has\n`restart: unless-stopped`, so it returns with Docker. If Docker itself is not\nenabled at boot, nothing comes back:\n\n```bash\nsudo systemctl enable docker\n```\n\n## Home lab, NAT and CGNAT\n\nThis is the case the feature exists for, and there is nothing extra to do. The\nmachine needs outbound internet and nothing else — no router configuration, no\nport forwarding, no static address, no dynamic DNS.\n\nOn CGNAT (common with mobile broadband and some fibre providers) your connection\nhas no public address at all, and a tunnel is the only thing that can work.\n\nA laptop is the same story. The tunnel follows the machine: move between\nnetworks and the connector reconnects on its own.\n\n## What this does not do\n\n**It does not decide what is published.** The connector carries a wildcard to\nTraefik, and Traefik still routes only services that opted in. Enabling a tunnel\npublishes nothing by itself.\n\n**It does not replace publishing a port.** A VPS with a public address and open\nports is faster (traffic goes straight to you) and depends on nobody. Both stay\nsupported; see [ADR 0025](../../development/adr/0025-cloudflare-tunnel.md) for the comparison.\n\n**The apex is not included.** `*.example.com` does not match `example.com`. Pass\n`--apex` at setup if the bare domain should reach the gateway too.\n\n**Traffic goes through Cloudflare.** That is the deal: they terminate TLS and see\nthe requests. If that is not acceptable, use Tailscale for private access\n([Expose the gateway through Tailscale](tailscale.md)) or publish a port directly.\n\n## See also\n\n- [ADR 0025](../../development/adr/0025-cloudflare-tunnel.md) — the architecture, and what was\n measured to arrive at it\n- [ADR 0023](../../development/adr/0023-flat-hostname-labels.md) — why hostnames are one label\n- [Configure Cloudflare DNS](cloudflare.md) — Cloudflare as a DNS provider, for certificates\n- [Security](../concepts/security.md) — what each exposure level means\n","text":"Publish through Cloudflare Tunnel Publish your services over HTTPS without opening a port, from a machine thathas no public address at all. This is optional. Portta works without it, and enabling it changes nothing aboutwhich services are reachable — that stays a separate, per-service decision. Why you might want it You have Without a tunnel With a tunnel a laptop *.localhost, and nothing anyone else can open a real HTTPS URL you can send someone a home lab behind NAT port forwarding on the router, if it is even possible nothing to forward a connection behind CGNAT impossible: there is no address to forward to works a VPS with a public address ports 80 and 443 open to the world ports stay closed no domain sslip.io over plain HTTP HTTPS on your own domain The one that is not a trade-off: behind CGNAT there is no alternative. Yourconnection has no public address to point a DNS record at. Tunnel, Proxy and Access are three different things They are often confused, and they solve different problems. You can use anycombination. Cloudflare Proxy (the orange cloud) sits in front of a server that alreadyhas a public address. Traffic goes to Cloudflare, then to your address. You stillneed an open port. Cloudflare Tunnel replaces the address entirely. cloudflared runs on yourmachine and dials out to Cloudflare, and traffic comes back down thatconnection. Nothing needs to reach you. Cloudflare Access is authentication at the edge. It decides who may reacha hostname, before the request ever gets to you. It is not a way in; it is a lockon a door that already exists. Portta treats them exactly that way: the tunnel is transport, Access is anoptional edge policy, and Portta ForwardAuth remains an independent origin-sidelogin. Neither mechanism depends on the other. What you need A domain on Cloudflare. Any plan, including the free one. Docker, which you already have. That is all. You do not need: a public IP address, an open port, a staticaddress, a certificate, or a Cloudflare API token. Setting it up 1. Create the tunnel Go to one.dash.cloudflare.com → Networks →Tunnels → Create a tunnel → Cloudflared. Give it a name (portta is a fine one) and select Create. 2. Copy the token The next screen shows an install command for your platform. It looks like: Do not run it. Copy only the long eyJ... string at the end — that is thetoken. It is the whole credential, so treat it like a password. You can skip the dashboard's \"public hostname\" step entirely. Portta configuresrouting itself, which is what lets one tunnel serve every project you will evercreate. 3. Give it to Portta From the panel: Settings → Cloudflare Tunnel, paste the domain and the token,and select Connect. Or from the command line, where the token is read from a file or a hidden promptrather than an argument: There is deliberately no --token flag. An argument is visible in ps to everyuser on the machine, and in your shell history. 4. Point the domain at the tunnel setup prints the one record you need: Create it in DNS → Records. This is the only manual Cloudflare step, and itis done once: the wildcard covers every project you create afterwards. Cloudflare proxies wildcard records on every plan, and Universal SSL covers*.example.com for free. The tunnel UUID in that target is not a secret. cfargotunnel.com only acceptsrecords from the account that owns the tunnel. 5. Start it test asks the internet for a hostname nothing is routed to. Traefik answering404 is success: it proves the whole path — edge, connector, proxy — works. Exposing a service Once the tunnel is connected, every project gets a hostname automatically: and, where a branch or pull request needs its own: Nothing needs to be created at Cloudflare for any of these. Starting acontainer is the whole operation. The connector carries one wildcard rule, andTraefik routes by hostname exactly as it always has. Everything sits in a single DNS label on purpose. *.example.com covers onelevel, and so does the free certificate; web.ecommerce.example.com would needCloudflare's paid Advanced Certificate Manager. SeeADR 0023. Turning it off From the panel, Disable; or: Either way, nothing in your Cloudflare account is touched. The tunnel, theDNS record and any Access policy stay exactly as they are, and Portta will neverdelete them for you. Re-enabling after a plain disable needs no token. Token security The token is the credential. Anyone who has it can run your tunnel. What Portta does with it: decodes it once into state/cloudflared/credentials.json, mode 0600, in adirectory that is mode 0700 never writes it to .env, which is the file the panel edits and you read never returns it from any API endpoint, in any state never puts it in a log line, a diagnostic, or an error message never passes it as a command-line argument, where ps would show it The panel reports Configured ✓ and offers Replace token. It cannot show youthe value again, and neither can the CLI. If you lose it, create a new token inthe dashboard — the old one keeps working until you rotate it there. Portta holds no Cloudflare API token and cannot change your account. Troubleshooting Each failure has a distinct signature, which is why the panel names themseparately rather than saying \"not working\". 530 from Cloudflare, \"Tunnel error\". Cloudflare has no connector for thistunnel. The connector is not running, or cannot reach the edge. 502 from Cloudflare. The connector is connected but could not reach yourservice. Traefik is down, or the container is stopped. Traefik's own 404. The tunnel works perfectly and nothing is routed at thathostname. Check the service has traefik.enable=true and is on the sharednetwork. Panel says \"Authentication error\". Cloudflare rejected the token. The tunnelwas probably deleted, or the token belongs to a different account. Create a newtunnel and paste the new token. The connector will not start. Check that outbound traffic is allowed: itdials Cloudflare on 7844/udp and falls back to 443/tcp. A firewall thatblocks outbound UDP is the usual cause. The hostname does not resolve at all. Nothing back means the wildcard CNAME is missing, or is not proxied. It must bethe orange cloud. It worked and then stopped after a reboot. The connector hasrestart: unless-stopped, so it returns with Docker. If Docker itself is notenabled at boot, nothing comes back: Home lab, NAT and CGNAT This is the case the feature exists for, and there is nothing extra to do. Themachine needs outbound internet and nothing else — no router configuration, noport forwarding, no static address, no dynamic DNS. On CGNAT (common with mobile broadband and some fibre providers) your connectionhas no public address at all, and a tunnel is the only thing that can work. A laptop is the same story. The tunnel follows the machine: move betweennetworks and the connector reconnects on its own. What this does not do It does not decide what is published. The connector carries a wildcard toTraefik, and Traefik still routes only services that opted in. Enabling a tunnelpublishes nothing by itself. It does not replace publishing a port. A VPS with a public address and openports is faster (traffic goes straight to you) and depends on nobody. Both staysupported; see ADR 0025 for the comparison. The apex is not included. *.example.com does not match example.com. Pass--apex at setup if the bare domain should reach the gateway too. Traffic goes through Cloudflare. That is the deal: they terminate TLS and seethe requests. If that is not acceptable, use Tailscale for private access(Expose the gateway through Tailscale) or publish a port directly. See also ADR 0025 — the architecture, and what wasmeasured to arrive at it ADR 0023 — why hostnames are one label Configure Cloudflare DNS — Cloudflare as a DNS provider, for certificates Security — what each exposure level means","headings":[{"id":"publish-through-cloudflare-tunnel","text":"Publish through Cloudflare Tunnel","level":1,"line":0},{"id":"why-you-might-want-it","text":"Why you might want it","level":2,"line":16},{"id":"tunnel-proxy-and-access-are-three-different-things","text":"Tunnel, Proxy and Access are three different things","level":2,"line":29},{"id":"what-you-need","text":"What you need","level":2,"line":50},{"id":"setting-it-up","text":"Setting it up","level":2,"line":58},{"id":"1-create-the-tunnel","text":"1. Create the tunnel","level":3,"line":60},{"id":"2-copy-the-token","text":"2. Copy the token","level":3,"line":67},{"id":"3-give-it-to-portta","text":"3. Give it to Portta","level":3,"line":82},{"id":"4-point-the-domain-at-the-tunnel","text":"4. Point the domain at the tunnel","level":3,"line":98},{"id":"5-start-it","text":"5. Start it","level":3,"line":118},{"id":"exposing-a-service","text":"Exposing a service","level":2,"line":128},{"id":"turning-it-off","text":"Turning it off","level":2,"line":154},{"id":"token-security","text":"Token security","level":2,"line":167},{"id":"troubleshooting","text":"Troubleshooting","level":2,"line":186},{"id":"home-lab-nat-and-cgnat","text":"Home lab, NAT and CGNAT","level":2,"line":235},{"id":"what-this-does-not-do","text":"What this does not do","level":2,"line":247},{"id":"see-also","text":"See also","level":2,"line":264}],"kind":"markdown"},{"slug":"public-access","title":"Enable public access","description":"Enable deliberate internet access to selected routes.","source":"docs/product/guides/public-access.md","audience":"user","section":"Guides","category":"Remote access","url":"/docs/public-access","markdown":"# Enable public access\n\nOpen the gateway's\n[Project access settings](http://127.0.0.1:8081/settings/general/project-access) to edit\nthe managed keys from the panel.\n\nDisabled by default. Turning it on is the most consequential thing the gateway\ncan do, so it is explicit, it shows you exactly what changes, and it asks.\n\n## Enabling\n\n```bash\nportta public enable\n```\n\nBefore changing anything it prints:\n\n- the wildcard domain that becomes reachable;\n- the interface (`0.0.0.0`, meaning every interface on the host);\n- the ports;\n- the TLS state, and a warning if traffic would be plaintext;\n- **the exact list of URLs** that would be served;\n- what is never published, in any profile.\n\nThen it asks. On yes it sets `PUBLIC_ENABLED=true` and\n`PORTTA_PROFILE=remote-public` in `.env` and applies the profile.\n\n```bash\nportta public status\nportta public disable\n```\n\n`disable` switches back to `remote-private` when Tailscale or a private domain\nis configured, and to `local` otherwise. Consumer projects keep running\nthroughout.\n\n## What public mode does and does not change\n\nIt changes **who can reach Traefik**. That is all.\n\nIt does not publish anything new. A service is still routed only when it sets\n`traefik.enable=true`, and databases and caches are still never on the shared\nnetwork. What was invisible stays invisible; what was already routed becomes\nreachable from the internet.\n\nNever public, in any profile:\n\n- PostgreSQL, MySQL, Redis, MongoDB and other datastores\n- the Docker API and the socket proxy\n- the Traefik dashboard\n\n`doctor` and the CI exposure job both fail if any of those bind `0.0.0.0`.\n\n## Prerequisites\n\n```env\nPUBLIC_ENABLED=false # public enable flips this\nPUBLIC_DOMAIN=dev.example.com\nTLS_ENABLED=true\nTLS_MODE=acme\nACME_EMAIL=you@example.com\n```\n\n`public enable` refuses without `PUBLIC_DOMAIN`, and if TLS is off it warns\nloudly and asks for a second confirmation.\n\nA wildcard `A` record for `*.dev.example.com` must point at the host's public\naddress: `portta dns setup`.\n\nFirewall: 80 and 443 have to be open. Nothing else does. See\n[Configure firewall rules](firewall.md).\n\n## Hardening\n\nPublic mode raises `aliasHeadersStrategy` to `delete`, so a client cannot\nforge a header Traefik manages by exploiting a backend that normalises\nunderscores.\n\nAnything routed is public unless its router opts into authentication. Portta\nships a working ForwardAuth middleware and one credential per protected host:\n\n```bash\nportta protect host demo-web.example.com --project demo --service web\n```\n\nThen opt a router in:\n\n```yaml\nlabels:\n - \"traefik.http.routers.web.middlewares=portta-forward-auth@file\"\n```\n\nPortta never edits the project label. See [Configure authentication](authentication.md)\nfor sessions and revocation.\n\n## Deciding\n\nUse the private profile. It is the default recommendation because it gives you\nthe same thing, remote access from anywhere, without an open port.\n\nPublic mode earns its keep for external webhooks, a demo for someone who cannot\njoin your tailnet, or testing something that must see a public certificate.\nWhen the reason passes, turn it off:\n\n```bash\nportta public disable\n```\n","text":"Enable public access Open the gateway'sProject access settings to editthe managed keys from the panel. Disabled by default. Turning it on is the most consequential thing the gatewaycan do, so it is explicit, it shows you exactly what changes, and it asks. Enabling Before changing anything it prints: the wildcard domain that becomes reachable; the interface (0.0.0.0, meaning every interface on the host); the ports; the TLS state, and a warning if traffic would be plaintext; the exact list of URLs that would be served; what is never published, in any profile. Then it asks. On yes it sets PUBLIC_ENABLED=true andPORTTA_PROFILE=remote-public in .env and applies the profile. disable switches back to remote-private when Tailscale or a private domainis configured, and to local otherwise. Consumer projects keep runningthroughout. What public mode does and does not change It changes who can reach Traefik. That is all. It does not publish anything new. A service is still routed only when it setstraefik.enable=true, and databases and caches are still never on the sharednetwork. What was invisible stays invisible; what was already routed becomesreachable from the internet. Never public, in any profile: PostgreSQL, MySQL, Redis, MongoDB and other datastores the Docker API and the socket proxy the Traefik dashboard doctor and the CI exposure job both fail if any of those bind 0.0.0.0. Prerequisites public enable refuses without PUBLIC_DOMAIN, and if TLS is off it warnsloudly and asks for a second confirmation. A wildcard A record for *.dev.example.com must point at the host's publicaddress: portta dns setup. Firewall: 80 and 443 have to be open. Nothing else does. SeeConfigure firewall rules. Hardening Public mode raises aliasHeadersStrategy to delete, so a client cannotforge a header Traefik manages by exploiting a backend that normalisesunderscores. Anything routed is public unless its router opts into authentication. Porttaships a working ForwardAuth middleware and one credential per protected host: Then opt a router in: Portta never edits the project label. See Configure authenticationfor sessions and revocation. Deciding Use the private profile. It is the default recommendation because it gives youthe same thing, remote access from anywhere, without an open port. Public mode earns its keep for external webhooks, a demo for someone who cannotjoin your tailnet, or testing something that must see a public certificate.When the reason passes, turn it off:","headings":[{"id":"enable-public-access","text":"Enable public access","level":1,"line":0},{"id":"enabling","text":"Enabling","level":2,"line":9},{"id":"what-public-mode-does-and-does-not-change","text":"What public mode does and does not change","level":2,"line":36},{"id":"prerequisites","text":"Prerequisites","level":2,"line":53},{"id":"hardening","text":"Hardening","level":2,"line":72},{"id":"deciding","text":"Deciding","level":2,"line":95}],"kind":"markdown"},{"slug":"remote-tunnels","title":"Open remote tunnels","description":"Reach a remote host through SSH and private bridges.","source":"docs/product/guides/remote-tunnels.md","audience":"user","section":"Guides","category":"Remote access","url":"/docs/remote-tunnels","markdown":"# Open remote tunnels\n\nReaching a VPS's private services from your machine, without opening a port on\nthe VPS.\n\n```text\nyour Mac VPS\n client bridge (127.0.0.1:33077)\n | |\n127.0.0.1:55432 ----SSH----> |\n v\n project's private network\n v\n postgres:5432\n```\n\n## Opening one\n\n```bash\nportta remote access open deploy@vps \\\n --project base-empresarial --service postgres\n```\n\n```text\n id 7f2a91\n remote base-empresarial/postgres:5432\n via deploy@vps\n local 127.0.0.1:55432\n```\n\nPoint TablePlus, DBeaver or `psql` at `127.0.0.1:55432`. The client never needs\nto know the VPS exists.\n\n```bash\nportta remote access list\nportta remote access close 7f2a91\nportta remote access close --all\n```\n\nClosing the tunnel leaves the remote bridge in place, since another tunnel may\nbe using it, and prints the command to close that too.\n\n## What it actually does\n\n1. Runs `portta access open` **on the VPS**. That bridge binds the VPS's\n loopback, exactly as a local one does. It is never published.\n2. Reads back the port it chose.\n3. Opens `ssh -N -L 127.0.0.1:<local>:127.0.0.1:<remote>`.\n4. Prints the local address and records the tunnel under `state/access/tunnels/`.\n\n## Over Tailscale SSH\n\nThe same command; the target is a tailnet name:\n\n```bash\nportta remote access open deploy@dev-vps --project base-empresarial --service postgres\n```\n\nAuthentication and audit come from your tailnet policy instead of\n`authorized_keys`, and no SSH port needs to be open on the internet.\n\n## SSH options, and why\n\n`StrictHostKeyChecking=accept-new` records a new host's key on first\nconnection and still refuses a *changed* one. That is the attack worth defending\nagainst. It is never `no`. Override with `PORTTA_SSH_HOST_KEY_POLICY=yes` if you\npre-populate `known_hosts`.\n\n`ExitOnForwardFailure=yes` means that if the local port turns out to be taken,\nSSH exits instead of connecting successfully while forwarding nothing. The failure mode\nthat flag prevents is a tunnel that looks open and silently does not work.\n\n`ServerAliveInterval=30` keeps the tunnel alive through a NAT idle timeout,\nwhich is what usually kills a long-lived database session.\n\n## Doing it by hand\n\nNothing here is magic:\n\n```bash\nssh deploy@vps 'cd portta && ./bin/portta access open --project base-empresarial --service postgres'\n# note the port it prints, say 33077\nssh -N -L 127.0.0.1:55432:127.0.0.1:33077 deploy@vps\n```\n\n## Troubleshooting\n\n**\"the SSH tunnel exited immediately\"** means the local port is taken, or the\nhost is unreachable. Try `--local-port` with a different number, or `ssh -v`.\n\n**\"the remote bridge did not report a port\"** means the gateway on the VPS is\nnot where we looked. Pass `--dir` if it is not in `~/portta`, and check\n`portta remote status deploy@vps`.\n\n**The tunnel dies after a while.** That is a NAT or firewall idle timeout.\n`ServerAliveInterval` covers the usual cases; a very aggressive middlebox may\nneed a shorter one in your `~/.ssh/config`.\n\n**Connection refused through a working tunnel.** The remote bridge is gone.\n`portta remote exec deploy@vps -- 'cd portta && ./bin/portta access list'`.\n","text":"Open remote tunnels Reaching a VPS's private services from your machine, without opening a port onthe VPS. Opening one Point TablePlus, DBeaver or psql at 127.0.0.1:55432. The client never needsto know the VPS exists. Closing the tunnel leaves the remote bridge in place, since another tunnel maybe using it, and prints the command to close that too. What it actually does Runs portta access open on the VPS. That bridge binds the VPS'sloopback, exactly as a local one does. It is never published. Reads back the port it chose. Opens ssh -N -L 127.0.0.1:<local>:127.0.0.1:<remote>. Prints the local address and records the tunnel under state/access/tunnels/. Over Tailscale SSH The same command; the target is a tailnet name: Authentication and audit come from your tailnet policy instead ofauthorized_keys, and no SSH port needs to be open on the internet. SSH options, and why StrictHostKeyChecking=accept-new records a new host's key on firstconnection and still refuses a changed one. That is the attack worth defendingagainst. It is never no. Override with PORTTA_SSH_HOST_KEY_POLICY=yes if youpre-populate known_hosts. ExitOnForwardFailure=yes means that if the local port turns out to be taken,SSH exits instead of connecting successfully while forwarding nothing. The failure modethat flag prevents is a tunnel that looks open and silently does not work. ServerAliveInterval=30 keeps the tunnel alive through a NAT idle timeout,which is what usually kills a long-lived database session. Doing it by hand Nothing here is magic: Troubleshooting \"the SSH tunnel exited immediately\" means the local port is taken, or thehost is unreachable. Try --local-port with a different number, or ssh -v. \"the remote bridge did not report a port\" means the gateway on the VPS isnot where we looked. Pass --dir if it is not in ~/portta, and checkportta remote status deploy@vps. The tunnel dies after a while. That is a NAT or firewall idle timeout.ServerAliveInterval covers the usual cases; a very aggressive middlebox mayneed a shorter one in your ~/.ssh/config. Connection refused through a working tunnel. The remote bridge is gone.portta remote exec deploy@vps -- 'cd portta && ./bin/portta access list'.","headings":[{"id":"open-remote-tunnels","text":"Open remote tunnels","level":1,"line":0},{"id":"opening-one","text":"Opening one","level":2,"line":16},{"id":"what-it-actually-does","text":"What it actually does","level":2,"line":42},{"id":"over-tailscale-ssh","text":"Over Tailscale SSH","level":2,"line":50},{"id":"ssh-options-and-why","text":"SSH options, and why","level":2,"line":61},{"id":"doing-it-by-hand","text":"Doing it by hand","level":2,"line":75},{"id":"troubleshooting","text":"Troubleshooting","level":2,"line":85}],"kind":"markdown"},{"slug":"authentication","title":"Configure authentication","description":"Manage panel accounts, sessions, tokens and protected services.","source":"docs/product/guides/authentication.md","audience":"user","section":"Guides","category":"Authentication and sharing","url":"/docs/authentication","markdown":"# Configure authentication\n\nPortta answers two different questions with two different mechanisms, and\nkeeping them apart is what makes each one simple.\n\n**The panel** asks who *you* are. It signs people in itself: a session cookie\nissued by the panel, a role that says what you may do, and optionally a Portta\ntoken for a CLI or a coding agent. Nothing in front of it decides anything.\n\n**A project hostname or a share** asks whether a request may reach an\napplication Portta routes but does not own. That is a separate process,\n`portta-auth`, which Traefik consults through ForwardAuth before the application\nreceives anything.\n\n```text\nbrowser -> panel -> session cookie -> the panel decides\nagent -> panel -> Bearer ptt_… -> the panel decides\nbrowser -> Traefik -> ForwardAuth -> login/session -> a project's application\n```\n\n## The panel\n\n### Two modes\n\n| `PORTTA_AUTH_MODE` | What it means |\n|---|---|\n| `disabled` (default) | Every request is the local operator, holding everything. Allowed **only** on loopback: the panel refuses to start otherwise. |\n| `required` | Everybody signs in. `/setup` creates the owner; everyone else is created by an administrator. |\n\n`disabled` is not a weaker password. It is the statement that reaching the panel\nalready means having the machine, which is true of `127.0.0.1` and of nothing\nelse. `portta web up --expose vpn|public|domain` refuses to run without\n`required`, and so does `portta config set panel.access`.\n\nSwitching `disabled → required` on an existing installation costs nothing: the\nnext boot has no owner, so the panel offers `/setup`. Switching back is accepted\nonly on loopback; the users and their tokens stay in the database, inert.\n\n### The first user\n\nA panel in `required` mode with no owner has exactly one page. Every route\nredirects to `/setup`, and the API answers `503 setup_required` to everything\nexcept `GET /api/health`, `GET /api/auth/status` and `POST /api/auth/setup`.\n\n```bash\n# in a browser\nopen http://127.0.0.1:8081/setup\n\n# or from the host, which is what a server with no browser needs\nprintf %s \"$PASSWORD\" | portta auth bootstrap \\\n --name 'Ada Lovelace' --email ada@example.com --password-stdin\n```\n\nOn a checkout, `just dev` and `just dev --demo` create that first account themselves:\n`Admin Demo` / `admin@admin.com` / `secret`. An installation never does.\n\nThe first account becomes the `owner`. Public sign-up does not exist: the\nendpoint is disabled, and the panel refuses a second one even if it is reached.\nTwo people opening `/setup` at the same moment produce one owner and one 409 —\nthe creation happens under an advisory lock.\n\n### Roles\n\n| Role | Holds |\n|---|---|\n| `owner` | Everything. Exactly one, and the only one who can transfer ownership. |\n| `admin` | Everything except acting on the owner. |\n| `developer` | Works: tasks, sessions, environments, containers, repositories. Does not administer, destroy, or open network paths. |\n| `viewer` | Reads, and their own tokens. |\n\nEvery API operation declares the permission it needs as `resource:action`, and\nthe OpenAPI document publishes it as `x-portta-permission`. A request with no\ncredential gets `401`; a request with one that is not enough gets `403`. Those\ntwo are never interchanged.\n\n### The rules a role cannot express\n\nFour things are true regardless of what somebody holds, because the owner is a\nperson rather than a permission. An administrator holds every statement the\nowner does; these are the whole difference, and they are why an admin cannot\ntake the panel:\n\n- **Nobody changes their own role, and nobody removes their own account.**\n- **Only the owner acts on the owner** — no role change, ban, password or\n removal, whoever is asking.\n- **`owner` is never assigned.** It moves through\n `POST /api/users/:id/transfer-ownership`, which promotes the target and demotes\n the caller in one transaction. There is no moment with two owners.\n- **The last owner cannot be removed.** A panel without one is a panel nobody\n can administer, and only before the bootstrap is that a legal state.\n\nTwo more follow from where the accounts live. Setting somebody's password\nrevokes every session they had, because a password that leaves the old sessions\nopen sets nothing. And administering accounts needs a signed-in person: a\nmachine token that has sat on a disk for six months is not what should be able\nto create an administrator.\n\n### Managing accounts\n\n| What | Permission | Where |\n|---|---|---|\n| List, read | `user:list`, `user:get` | `GET /api/users`, `portta users list` |\n| Create | `user:create` | `POST /api/users`, `portta users create` |\n| Change a role | `user:set-role` | `PATCH /api/users/:id/role`, `portta users set-role` |\n| Set a password | `user:set-password` | `PATCH /api/users/:id/password`, `portta users set-password` |\n| Ban, unban | `user:ban` | `PATCH /api/users/:id/ban` |\n| Remove | `user:delete` | `DELETE /api/users/:id`, `portta users remove` |\n| See and end sessions | `session:list`, `session:revoke` | `GET`/`DELETE /api/users/:id/sessions` |\n| Which Projects somebody reaches | `project:members` | `PUT /api/users/:id/projects` |\n| Hand the panel over | `user:set-role`, and only the owner | `POST /api/users/:id/transfer-ownership` |\n\nRemoving an account takes its sessions, tokens and memberships with it. The work\nit did stays, under the name it was done with.\n\nOwner and admin see every Project, so a membership list does not apply to them\nand setting one is refused. Promoting somebody to admin clears the memberships\nthey had, because leaving them would suggest a boundary nothing enforces.\n\nAll of it is also **Settings → Users** in the panel, for somebody who holds\n`user:list`. An action a rule would refuse is not offered there: there is no\n\"change your own role\", no ban on the owner from an administrator, and no\nremoval of the last owner. Removing asks for the account's email to be typed\nfirst.\n\n### Access by Project\n\nA role says what somebody may do. A membership says where. `owner` and `admin`\nsee every Project; a `developer` and a `viewer` see the ones somebody put them\nin, and nothing else — not the tasks, not the environments, not the activity,\nnot the events.\n\n```bash\nportta users grant ada@example.com shop\nportta users revoke ada@example.com shop\n```\n\nEvery route that names a resource asks twice: the permission first, at the door,\nand the Project second, once the resource has been read and it is known which\none it belongs to. Which Project a thing is in comes from where it actually\nlives:\n\n| Resource | Its Project |\n|---|---|\n| Project, repository, task, note, attachment, work session, activity | the row's own `project_id` |\n| Environment, service, container, logs, per-environment resources | the Project that adopted the environment; **none** if no Project did |\n| Bridge, forwarder, share | the environment it targets |\n| Docker's raw host inventory, gateway, network, tunnel, settings, users, audit | nothing: they are about the host, and the permission decides alone |\n\nAn environment no Project adopted has no membership to check, so it is visible\nto `owner` and `admin` and to nobody else. The same is true of a repository the\nhost scanned that nobody registered, and of an event with no Project in it.\n\n**Listings filter; named resources refuse.** Asking for the Projects returns\nyours, not a 403 about somebody else's. Asking for one by name that you are not\nin is a 403. The Overview sums only what you can see, and the event stream\ndelivers only events about it — losing a membership closes the door on the next\nrequest, not the next sign-in.\n\nRead-only mode (`PORTTA_WEB_READ_ONLY=true`) intersects every principal with the\nreads, whoever signed in.\n\n### Sessions and the second factor\n\nSign-in sets `portta.session_token`: `HttpOnly`, `SameSite=Lax`, `Path=/`, and\n`Secure` whenever `PORTTA_PANEL_URL` is HTTPS. Sessions last seven days and are\nrefreshed daily. Signing out revokes the session; banning a user takes effect on\ntheir *next request*, not their next sign-in.\n\nSign-in, TOTP verification and backup codes are rate-limited to five attempts in\nten minutes, per address. A team behind one NAT is one address, which is what\n`PORTTA_AUTH_SIGNIN_ATTEMPTS` is for; it accepts 3 to 100, and anything else is\nread as the default, so the limit cannot be configured away. A user who has turned on a second factor is sent to `/two-factor`\nafter their password is accepted.\n\n**Settings → Security** is where somebody turns it on. The panel asks for the\npassword, shows the QR code and the secret behind it, and only counts the factor\nas on once a code from the app comes back — an interrupted setup leaves the\naccount exactly as it was. The backup codes are shown once, in a dialog that\ndoes not close on an escape key. Turning it off asks for the password again.\n\nThe same page lists every session of the account, marks the browser it is being\nread in, and ends the others one at a time. An administrator sees the same list\nfor somebody else under **Settings → Users**, where the only action is ending\nall of them at once: a session id is not something anybody recognises, and the\nquestion that gets asked is \"sign this account out everywhere\".\n\nThere is no email transport in a self-hosted panel, so there is no reset link.\nA forgotten password is reset from the host that owns the panel:\n\n```bash\nprintf %s \"$NEW\" | portta auth reset-password ada@example.com --password-stdin\nportta auth reset-password ada@example.com # or let it generate one, shown once\n```\n\nThat runs inside the panel's own container, where the database already is. It is\ndeliberately not an API call: the case it exists for is the one where no\ncredential works. Being able to run it means having the machine, which is the\nsame authority the owner had when they created the account. Every session of\nthat account is ended.\n\n### Tokens for the CLI and agents\n\nA Portta token is a `ptt_`-prefixed Bearer credential belonging to a user. It\nnever exceeds its owner's role: what it holds is the intersection of its own\nscopes and that role, computed on every request. Lowering somebody's role lowers\nevery token they made without touching the tokens; banning them stops all of\nthem at once; revoking one takes effect on the next request that carries it.\n\n```bash\nportta auth token create --name laptop # the secret is shown once\nportta auth token create --name ci --scopes task:read,task:write --expires-in-days 90\nportta auth token list\nportta auth token revoke <id>\n```\n\nAsking for no scopes gives the sensible default for what the token is: a\nperson's token (`--human`) holds their whole role, and an agent's holds what\nagents hold — a developer minus the three things that change how the panel\nbehaves. Asking for scopes the owner does not hold is a 400 that names exactly\nwhich ones did not fit.\n\nYour tokens are yours to make and revoke. Somebody else's needs `user:list` to\nsee (`--all`) and `user:update` to revoke, because revoking a colleague's\ncredential is an administrative act — and the one that makes a lost laptop\nsomebody else's problem to solve.\n\nThe panel accepts a token as `Authorization: Bearer ptt_…` and in no other\nform; `x-api-key` is not accepted. Housekeeping disables a token that expired\nmore than thirty days ago and deletes one revoked more than ninety days ago.\n\n**Settings → API tokens** is the same thing in the panel: your tokens by\ndefault, everybody's for somebody with `user:list`. A new secret appears once,\nin a dialog that will not close on an escape key and asks the person to say they\ncopied it — because the panel keeps a hash, and a lost secret means making\nanother token.\n\n### Signing a terminal in\n\n```bash\nportta auth login --url http://127.0.0.1:8081 # asks for the token, without echoing it\nportta auth status # who this terminal is\nportta auth logout\nportta auth whoami # every panel this host has a credential for\n```\n\n`login` checks the token against the panel before saving it, so a typo fails\nhere rather than on the next command. The store is\n`~/.config/portta/credentials.json` (`$XDG_CONFIG_HOME` respected), mode 0600,\none entry per panel URL — a laptop panel and a server panel are not the same\ncredential.\n\nWhat a command sends is, in order: `--token`, then `PORTTA_TOKEN`, then whatever\n`login` saved for that panel. `portta mcp` uses the same resolution, so an agent\nconfigured once keeps working after a token is rotated. A non-loopback panel URL\nstill needs `--allow-remote`: that URL is where a credential would be sent.\n\n`logout` forgets the credential; it does not revoke the token. The message says\nso, because the two are different answers to \"my laptop is gone\".\n\n### Agents in `disabled` mode\n\nWith no sign-in there is nobody to be, so `X-Portta-Actor` is attribution: it\nsays which caller behind the machine this is. The one thing it decides is that a\nrequest announcing itself as an agent is held to what agents may do — the\n`agentPermissions` setting, which defaults to a developer minus the three things\nthat change how the panel behaves (`environment:settings`, `repository:manage`,\n`github:sync`).\n\nThat setting is editable in **Settings → General → Panel**, one permission at a\ntime, and `GET`/`PUT /api/settings/agent-permissions` is the same list for a\nscript. It is a ceiling in both modes: with accounts on, an agent holds the\nintersection of this list and the role of whoever the token belongs to.\n\n## Project hostnames and shares\n\n`portta-auth` publishes no host port, has no Docker socket or database, and\nmounts `state/auth/protections.json` read-only. Credentials use Portta's scrypt\nformat. Hashes never appear in generated\nTraefik YAML. This process knows nothing about the panel, its users or its\ntokens.\n\nA successful login there sets `__portta_session` as `HttpOnly`, `SameSite=Lax`,\n`Path=/`, host-only, and `Secure` on HTTPS, for twelve hours. Each protected host\nhas an epoch; changing or removing its credential invalidates the sessions that\ncame before. `/__portta/auth` is reserved on every protected host, and only\nsame-host paths are accepted as redirects.\n\nREST, webhook, health-check, SSE and WebSocket requests never receive a login\nredirect. They get 401; interactive authentication happens through the login page.\n\nFailed logins are delayed progressively; five failures in ten minutes lock that\nhost/IP pair for fifteen minutes. Logs carry scope, client address and outcome —\nnever a password, cookie or Authorization value.\n\n### Shares\n\n```bash\nportta share list\nportta share revoke a7f3\nportta share gc\n```\n\nProtected-share passwords are shown once. Rotation bumps the share epoch; revoke\nand garbage collection remove its protection record.\n\n### Protecting a project hostname\n\nPortta never edits a consumer project's router. Create the host record, then opt\nthat router into the generated middleware in the project's own Compose file:\n\n```bash\nportta protect host demo-web.example.com --project demo --service web\n```\n\n```yaml\nlabels:\n - \"traefik.http.routers.demo-web.middlewares=portta-forward-auth@file\"\n```\n\nInspect or remove records without exposing hashes:\n\n```bash\nportta protect status\nportta protect status demo-web.example.com\nportta protect remove demo-web.example.com\n```\n\nRemoving the record does not edit the project label. Until the label is removed,\nthe unresolved protection fails closed.\n\n## State and recovery\n\n- `PORTTA_AUTH_SECRET` in `.env` signs the panel's sessions and tokens, and the\n ForwardAuth process's cookies. `portta bootstrap` generates it. Rotating it\n signs everybody out of both.\n- The panel's users, sessions and tokens live in its PostgreSQL database.\n- `state/auth/protections.json` holds project and share credentials. It is\n versioned, atomic and mode 0600.\n- `config/traefik/dynamic/portta-auth.yaml` contains only services, routers and\n middleware — no credential material.\n- `portta doctor` checks the mode against the bind address, the secret, the\n database, and the auth container's health.\n\nSee [ADR 0035](../../development/adr/0035-authentication-lives-in-the-panel.md) for why the panel\nauthenticates itself, and [ADR 0027](../../development/adr/0027-forward-authentication-service.md)\nfor the ForwardAuth trust boundary.\n","text":"Configure authentication Portta answers two different questions with two different mechanisms, andkeeping them apart is what makes each one simple. The panel asks who you are. It signs people in itself: a session cookieissued by the panel, a role that says what you may do, and optionally a Porttatoken for a CLI or a coding agent. Nothing in front of it decides anything. A project hostname or a share asks whether a request may reach anapplication Portta routes but does not own. That is a separate process,portta-auth, which Traefik consults through ForwardAuth before the applicationreceives anything. The panel Two modes PORTTA_AUTH_MODE What it means disabled (default) Every request is the local operator, holding everything. Allowed only on loopback: the panel refuses to start otherwise. required Everybody signs in. /setup creates the owner; everyone else is created by an administrator. disabled is not a weaker password. It is the statement that reaching the panelalready means having the machine, which is true of 127.0.0.1 and of nothingelse. portta web up --expose vpn|public|domain refuses to run withoutrequired, and so does portta config set panel.access. Switching disabled → required on an existing installation costs nothing: thenext boot has no owner, so the panel offers /setup. Switching back is acceptedonly on loopback; the users and their tokens stay in the database, inert. The first user A panel in required mode with no owner has exactly one page. Every routeredirects to /setup, and the API answers 503 setup_required to everythingexcept GET /api/health, GET /api/auth/status and POST /api/auth/setup. On a checkout, just dev and just dev --demo create that first account themselves:Admin Demo / admin@admin.com / secret. An installation never does. The first account becomes the owner. Public sign-up does not exist: theendpoint is disabled, and the panel refuses a second one even if it is reached.Two people opening /setup at the same moment produce one owner and one 409 —the creation happens under an advisory lock. Roles Role Holds owner Everything. Exactly one, and the only one who can transfer ownership. admin Everything except acting on the owner. developer Works: tasks, sessions, environments, containers, repositories. Does not administer, destroy, or open network paths. viewer Reads, and their own tokens. Every API operation declares the permission it needs as resource:action, andthe OpenAPI document publishes it as x-portta-permission. A request with nocredential gets 401; a request with one that is not enough gets 403. Thosetwo are never interchanged. The rules a role cannot express Four things are true regardless of what somebody holds, because the owner is aperson rather than a permission. An administrator holds every statement theowner does; these are the whole difference, and they are why an admin cannottake the panel: Nobody changes their own role, and nobody removes their own account. Only the owner acts on the owner — no role change, ban, password orremoval, whoever is asking. owner is never assigned. It moves throughPOST /api/users/:id/transfer-ownership, which promotes the target and demotesthe caller in one transaction. There is no moment with two owners. The last owner cannot be removed. A panel without one is a panel nobodycan administer, and only before the bootstrap is that a legal state. Two more follow from where the accounts live. Setting somebody's passwordrevokes every session they had, because a password that leaves the old sessionsopen sets nothing. And administering accounts needs a signed-in person: amachine token that has sat on a disk for six months is not what should be ableto create an administrator. Managing accounts What Permission Where List, read user:list, user:get GET /api/users, portta users list Create user:create POST /api/users, portta users create Change a role user:set-role PATCH /api/users/:id/role, portta users set-role Set a password user:set-password PATCH /api/users/:id/password, portta users set-password Ban, unban user:ban PATCH /api/users/:id/ban Remove user:delete DELETE /api/users/:id, portta users remove See and end sessions session:list, session:revoke GET/DELETE /api/users/:id/sessions Which Projects somebody reaches project:members PUT /api/users/:id/projects Hand the panel over user:set-role, and only the owner POST /api/users/:id/transfer-ownership Removing an account takes its sessions, tokens and memberships with it. The workit did stays, under the name it was done with. Owner and admin see every Project, so a membership list does not apply to themand setting one is refused. Promoting somebody to admin clears the membershipsthey had, because leaving them would suggest a boundary nothing enforces. All of it is also Settings → Users in the panel, for somebody who holdsuser:list. An action a rule would refuse is not offered there: there is no\"change your own role\", no ban on the owner from an administrator, and noremoval of the last owner. Removing asks for the account's email to be typedfirst. Access by Project A role says what somebody may do. A membership says where. owner and adminsee every Project; a developer and a viewer see the ones somebody put themin, and nothing else — not the tasks, not the environments, not the activity,not the events. Every route that names a resource asks twice: the permission first, at the door,and the Project second, once the resource has been read and it is known whichone it belongs to. Which Project a thing is in comes from where it actuallylives: Resource Its Project Project, repository, task, note, attachment, work session, activity the row's own project_id Environment, service, container, logs, per-environment resources the Project that adopted the environment; none if no Project did Bridge, forwarder, share the environment it targets Docker's raw host inventory, gateway, network, tunnel, settings, users, audit nothing: they are about the host, and the permission decides alone An environment no Project adopted has no membership to check, so it is visibleto owner and admin and to nobody else. The same is true of a repository thehost scanned that nobody registered, and of an event with no Project in it. Listings filter; named resources refuse. Asking for the Projects returnsyours, not a 403 about somebody else's. Asking for one by name that you are notin is a 403. The Overview sums only what you can see, and the event streamdelivers only events about it — losing a membership closes the door on the nextrequest, not the next sign-in. Read-only mode (PORTTA_WEB_READ_ONLY=true) intersects every principal with thereads, whoever signed in. Sessions and the second factor Sign-in sets portta.session_token: HttpOnly, SameSite=Lax, Path=/, andSecure whenever PORTTA_PANEL_URL is HTTPS. Sessions last seven days and arerefreshed daily. Signing out revokes the session; banning a user takes effect ontheir next request, not their next sign-in. Sign-in, TOTP verification and backup codes are rate-limited to five attempts inten minutes, per address. A team behind one NAT is one address, which is whatPORTTA_AUTH_SIGNIN_ATTEMPTS is for; it accepts 3 to 100, and anything else isread as the default, so the limit cannot be configured away. A user who has turned on a second factor is sent to /two-factorafter their password is accepted. Settings → Security is where somebody turns it on. The panel asks for thepassword, shows the QR code and the secret behind it, and only counts the factoras on once a code from the app comes back — an interrupted setup leaves theaccount exactly as it was. The backup codes are shown once, in a dialog thatdoes not close on an escape key. Turning it off asks for the password again. The same page lists every session of the account, marks the browser it is beingread in, and ends the others one at a time. An administrator sees the same listfor somebody else under Settings → Users, where the only action is endingall of them at once: a session id is not something anybody recognises, and thequestion that gets asked is \"sign this account out everywhere\". There is no email transport in a self-hosted panel, so there is no reset link.A forgotten password is reset from the host that owns the panel: That runs inside the panel's own container, where the database already is. It isdeliberately not an API call: the case it exists for is the one where nocredential works. Being able to run it means having the machine, which is thesame authority the owner had when they created the account. Every session ofthat account is ended. Tokens for the CLI and agents A Portta token is a ptt_-prefixed Bearer credential belonging to a user. Itnever exceeds its owner's role: what it holds is the intersection of its ownscopes and that role, computed on every request. Lowering somebody's role lowersevery token they made without touching the tokens; banning them stops all ofthem at once; revoking one takes effect on the next request that carries it. Asking for no scopes gives the sensible default for what the token is: aperson's token (--human) holds their whole role, and an agent's holds whatagents hold — a developer minus the three things that change how the panelbehaves. Asking for scopes the owner does not hold is a 400 that names exactlywhich ones did not fit. Your tokens are yours to make and revoke. Somebody else's needs user:list tosee (--all) and user:update to revoke, because revoking a colleague'scredential is an administrative act — and the one that makes a lost laptopsomebody else's problem to solve. The panel accepts a token as Authorization: Bearer ptt_… and in no otherform; x-api-key is not accepted. Housekeeping disables a token that expiredmore than thirty days ago and deletes one revoked more than ninety days ago. Settings → API tokens is the same thing in the panel: your tokens bydefault, everybody's for somebody with user:list. A new secret appears once,in a dialog that will not close on an escape key and asks the person to say theycopied it — because the panel keeps a hash, and a lost secret means makinganother token. Signing a terminal in login checks the token against the panel before saving it, so a typo failshere rather than on the next command. The store is~/.config/portta/credentials.json ($XDG_CONFIG_HOME respected), mode 0600,one entry per panel URL — a laptop panel and a server panel are not the samecredential. What a command sends is, in order: --token, then PORTTA_TOKEN, then whateverlogin saved for that panel. portta mcp uses the same resolution, so an agentconfigured once keeps working after a token is rotated. A non-loopback panel URLstill needs --allow-remote: that URL is where a credential would be sent. logout forgets the credential; it does not revoke the token. The message saysso, because the two are different answers to \"my laptop is gone\". Agents in disabled mode With no sign-in there is nobody to be, so X-Portta-Actor is attribution: itsays which caller behind the machine this is. The one thing it decides is that arequest announcing itself as an agent is held to what agents may do — theagentPermissions setting, which defaults to a developer minus the three thingsthat change how the panel behaves (environment:settings, repository:manage,github:sync). That setting is editable in Settings → General → Panel, one permission at atime, and GET/PUT /api/settings/agent-permissions is the same list for ascript. It is a ceiling in both modes: with accounts on, an agent holds theintersection of this list and the role of whoever the token belongs to. Project hostnames and shares portta-auth publishes no host port, has no Docker socket or database, andmounts state/auth/protections.json read-only. Credentials use Portta's scryptformat. Hashes never appear in generatedTraefik YAML. This process knows nothing about the panel, its users or itstokens. A successful login there sets __portta_session as HttpOnly, SameSite=Lax,Path=/, host-only, and Secure on HTTPS, for twelve hours. Each protected hosthas an epoch; changing or removing its credential invalidates the sessions thatcame before. /__portta/auth is reserved on every protected host, and onlysame-host paths are accepted as redirects. REST, webhook, health-check, SSE and WebSocket requests never receive a loginredirect. They get 401; interactive authentication happens through the login page. Failed logins are delayed progressively; five failures in ten minutes lock thathost/IP pair for fifteen minutes. Logs carry scope, client address and outcome —never a password, cookie or Authorization value. Shares Protected-share passwords are shown once. Rotation bumps the share epoch; revokeand garbage collection remove its protection record. Protecting a project hostname Portta never edits a consumer project's router. Create the host record, then optthat router into the generated middleware in the project's own Compose file: Inspect or remove records without exposing hashes: Removing the record does not edit the project label. Until the label is removed,the unresolved protection fails closed. State and recovery PORTTA_AUTH_SECRET in .env signs the panel's sessions and tokens, and theForwardAuth process's cookies. portta bootstrap generates it. Rotating itsigns everybody out of both. The panel's users, sessions and tokens live in its PostgreSQL database. state/auth/protections.json holds project and share credentials. It isversioned, atomic and mode 0600. config/traefik/dynamic/portta-auth.yaml contains only services, routers andmiddleware — no credential material. portta doctor checks the mode against the bind address, the secret, thedatabase, and the auth container's health. See ADR 0035 for why the panelauthenticates itself, and ADR 0027for the ForwardAuth trust boundary.","headings":[{"id":"configure-authentication","text":"Configure authentication","level":1,"line":0},{"id":"the-panel","text":"The panel","level":2,"line":20},{"id":"two-modes","text":"Two modes","level":3,"line":22},{"id":"the-first-user","text":"The first user","level":3,"line":38},{"id":"roles","text":"Roles","level":3,"line":61},{"id":"the-rules-a-role-cannot-express","text":"The rules a role cannot express","level":3,"line":75},{"id":"managing-accounts","text":"Managing accounts","level":3,"line":97},{"id":"access-by-project","text":"Access by Project","level":3,"line":124},{"id":"sessions-and-the-second-factor","text":"Sessions and the second factor","level":3,"line":161},{"id":"tokens-for-the-cli-and-agents","text":"Tokens for the CLI and agents","level":3,"line":200},{"id":"signing-a-terminal-in","text":"Signing a terminal in","level":3,"line":236},{"id":"agents-in-disabled-mode","text":"Agents in disabled mode","level":3,"line":259},{"id":"project-hostnames-and-shares","text":"Project hostnames and shares","level":2,"line":273},{"id":"shares","text":"Shares","level":3,"line":294},{"id":"protecting-a-project-hostname","text":"Protecting a project hostname","level":3,"line":305},{"id":"state-and-recovery","text":"State and recovery","level":2,"line":330}],"kind":"markdown"},{"slug":"sharing","title":"Share a service","description":"Share a service with an expiry and optional protection.","source":"docs/product/guides/sharing.md","audience":"user","section":"Guides","category":"Authentication and sharing","url":"/docs/sharing","markdown":"# Share a service\n\nSomebody wants to look at the branch you are on. Before this existed the\nchoices were: not routed at all, routed on the VPN so everyone on the tailnet\ncan reach it, or `PUBLIC_ENABLED=true`, which puts **every opted-in service on\nthe host** on the internet. None of those is \"show this one thing to this one\nperson until tomorrow\".\n\nA share is that. It is an **additional hostname** for one service, with an\nexpiry, and it changes nothing about the project.\n\n```text\nExposure Protected · reviewer · expires in 6 h Revoke Regenerate\n https://storefront-web-a7f3.share.dev.example.com\n```\n\n## The three states\n\n| State | What it is |\n|---|---|\n| `private` | **The absence of a share.** The default, and the only thing that existed before. Not a deny rule, and nothing is written anywhere |\n| `protected` | An additional hostname behind a generated password |\n| `public` | An additional hostname with no password. Refused unless `PUBLIC_ENABLED=true` and `PUBLIC_DOMAIN` is set |\n\nShared hostnames live under `share.<domain>`, kept visibly apart from project\nhostnames and covered by one wildcard record.\n\n## Making one\n\nFrom the panel: open a service, and the Exposure section offers an expiry and\nthe two modes. That is where the decision is made, so that is where the button\nis; the CLI manages what already exists.\n\nA protected share generates its password (twenty characters over a thirty-two\nsymbol alphabet, so about a hundred bits), **shows it exactly once**, and stores\nonly its scrypt hash in `state/auth/protections.json`. No API response ever contains it again, and regenerating\nreplaces the hash and shows a new one, which is also what you do when you lose\nit.\n\n## Managing them\n\n```bash\n./bin/portta share list # every share, its mode, and when it expires\n./bin/portta share revoke a7f3 # remove one\n./bin/portta share gc # remove the ones that have expired\n```\n\nThe panel and the CLI manage the same objects, the way they already do for\n[access bridges](tcp-access.md). Active shares are counted on the Overview, and\nexpired or dangling ones show up in the diagnostics, because an exposure nobody\nremembers is exactly the one worth surfacing.\n\n## What is actually written\n\nOne generated file, `config/traefik/dynamic/portta-shares.yaml`, which\nTraefik already watches and hot-reloads:\n\n```yaml\nhttp:\n routers:\n portta-share-a7f3:\n rule: \"Host(`storefront-web-a7f3.share.dev.example.com`)\"\n entryPoints: [websecure]\n middlewares: [portta-forward-auth]\n service: portta-share-a7f3\n services:\n portta-share-a7f3:\n loadBalancer:\n servers:\n - url: \"http://storefront-web-1:3000\"\n```\n\nThe credential is not in this YAML. `portta-auth.yaml` defines the shared\nmiddleware and the reserved login router; the auth service reads the private\nstore read-only.\n\nTwo details that matter:\n\n**The backend is the container name, never the Compose service alias.** On the\nshared network two projects can both alias `web`, and only the container name\nis unique. The cost is that recreating the container under a different\nnamespace breaks the share, which a diagnostic then flags.\n\n**The project's own router is untouched.** A share is an addition, so revoking\none deletes a block from this file and nothing about the project changes either\nway. The panel cannot rewrite a container's labels, and would not if it could:\nthat is the project's configuration, not the gateway's\n([ADR 0001](../../development/adr/0001-decoupled-infrastructure.md)).\n\nThe panel may write exactly four filenames in that directory and refuses every\nother path in its own process, so `middlewares.yaml`, `tcp.yaml` and anything\nyou put there yourself are never touched\n([ADR 0011](../../development/adr/0011-bounded-traefik-write-surface.md)).\n\n## What is refused\n\nRefusals rather than warnings, following the precedent\n`portta service publish` already set for datastores:\n\n- **a service whose kind is not `http`.** A database is reached with\n `portta access open` or by hostname on its own entrypoint, never on the\n web entrypoint. See [Open a TCP bridge](tcp-access.md).\n- **a service that is not on the shared network.** Traefik dials backends over\n it; a service that never joined has nothing to route to.\n- **`public` without `PUBLIC_ENABLED` and `PUBLIC_DOMAIN`.**\n- **`protected` when TLS is off on a remote profile.** A password sent in clear\n is not protection, and pretending otherwise is worse than refusing.\n- **an expiry outside one minute to seven days.** There is no unlimited share:\n an exposure with no end is one nobody remembers to close.\n- **a second share for the same container.** Revoke or regenerate the one that\n exists.\n\n## What this is not\n\nNot multi-user identity for your project. One credential, no users and no roles:\nit is a door you open for an afternoon. See [Configure authentication](authentication.md)\nfor protecting a project-owned router with the same login.\n\nNot a tunnel either. A share is only reachable where the gateway already is: on\nthe VPN for the private profile, on the internet only when public access is\nalready enabled. It exposes one more hostname, never one more network.\n","text":"Share a service Somebody wants to look at the branch you are on. Before this existed thechoices were: not routed at all, routed on the VPN so everyone on the tailnetcan reach it, or PUBLIC_ENABLED=true, which puts every opted-in service onthe host on the internet. None of those is \"show this one thing to this oneperson until tomorrow\". A share is that. It is an additional hostname for one service, with anexpiry, and it changes nothing about the project. The three states State What it is private The absence of a share. The default, and the only thing that existed before. Not a deny rule, and nothing is written anywhere protected An additional hostname behind a generated password public An additional hostname with no password. Refused unless PUBLIC_ENABLED=true and PUBLIC_DOMAIN is set Shared hostnames live under share.<domain>, kept visibly apart from projecthostnames and covered by one wildcard record. Making one From the panel: open a service, and the Exposure section offers an expiry andthe two modes. That is where the decision is made, so that is where the buttonis; the CLI manages what already exists. A protected share generates its password (twenty characters over a thirty-twosymbol alphabet, so about a hundred bits), shows it exactly once, and storesonly its scrypt hash in state/auth/protections.json. No API response ever contains it again, and regeneratingreplaces the hash and shows a new one, which is also what you do when you loseit. Managing them The panel and the CLI manage the same objects, the way they already do foraccess bridges. Active shares are counted on the Overview, andexpired or dangling ones show up in the diagnostics, because an exposure nobodyremembers is exactly the one worth surfacing. What is actually written One generated file, config/traefik/dynamic/portta-shares.yaml, whichTraefik already watches and hot-reloads: The credential is not in this YAML. portta-auth.yaml defines the sharedmiddleware and the reserved login router; the auth service reads the privatestore read-only. Two details that matter: The backend is the container name, never the Compose service alias. On theshared network two projects can both alias web, and only the container nameis unique. The cost is that recreating the container under a differentnamespace breaks the share, which a diagnostic then flags. The project's own router is untouched. A share is an addition, so revokingone deletes a block from this file and nothing about the project changes eitherway. The panel cannot rewrite a container's labels, and would not if it could:that is the project's configuration, not the gateway's(ADR 0001). The panel may write exactly four filenames in that directory and refuses everyother path in its own process, so middlewares.yaml, tcp.yaml and anythingyou put there yourself are never touched(ADR 0011). What is refused Refusals rather than warnings, following the precedentportta service publish already set for datastores: a service whose kind is not http. A database is reached withportta access open or by hostname on its own entrypoint, never on theweb entrypoint. See Open a TCP bridge. a service that is not on the shared network. Traefik dials backends overit; a service that never joined has nothing to route to. public without PUBLIC_ENABLED and PUBLIC_DOMAIN. protected when TLS is off on a remote profile. A password sent in clearis not protection, and pretending otherwise is worse than refusing. an expiry outside one minute to seven days. There is no unlimited share:an exposure with no end is one nobody remembers to close. a second share for the same container. Revoke or regenerate the one thatexists. What this is not Not multi-user identity for your project. One credential, no users and no roles:it is a door you open for an afternoon. See Configure authenticationfor protecting a project-owned router with the same login. Not a tunnel either. A share is only reachable where the gateway already is: onthe VPN for the private profile, on the internet only when public access isalready enabled. It exposes one more hostname, never one more network.","headings":[{"id":"share-a-service","text":"Share a service","level":1,"line":0},{"id":"the-three-states","text":"The three states","level":2,"line":16},{"id":"making-one","text":"Making one","level":2,"line":27},{"id":"managing-them","text":"Managing them","level":2,"line":39},{"id":"what-is-actually-written","text":"What is actually written","level":2,"line":52},{"id":"what-is-refused","text":"What is refused","level":2,"line":94},{"id":"what-this-is-not","text":"What this is not","level":2,"line":112}],"kind":"markdown"},{"slug":"database-access","title":"Connect to a database","description":"Choose a terminal client, GUI bridge or private address.","source":"docs/product/guides/database-access.md","audience":"user","section":"Guides","category":"Databases and TCP services","url":"/docs/database-access","markdown":"# Connect to a database\n\nShort version, by situation. The reasoning is in\n[Open a TCP bridge](tcp-access.md).\n\n## From the application\n\nNothing changes and nothing is needed. The application reaches\n`postgres:5432` over the project's own private network, exactly as it always\ndid.\n\n## From a GUI on this machine\n\n```bash\nportta access open --project base-empresarial --service postgres\n# -> 127.0.0.1:33077\n```\n\n| | |\n|---|---|\n| Host | `127.0.0.1` |\n| Port | the one printed |\n| User / password / database | the project's own, from its `.env` — or from the panel's Connect panel, which reads the container environment on demand |\n\nAdd `--local-port 55432` to keep a saved connection working across sessions.\nOtherwise the kernel picks a new free port each time, which is what lets four\ndatabases be open at once.\n\n```bash\nportta access list\nportta access close --project base-empresarial\n```\n\n## From the terminal, or from an agent\n\nDo not open a bridge. Run the client inside the project's network:\n\n```bash\nportta db psql --project base-empresarial\nportta db psql --project base-empresarial -- -c 'select count(*) from users'\nportta db psql --project base-empresarial -- -f migrations/001.sql\n```\n\nNothing is published, and the container is removed when you exit. Credentials\ncome from the target container's own environment.\n\nMySQL works the same way:\n\n```bash\nportta db mysql --project some-project\n```\n\nOr, with no gateway involved at all:\n\n```bash\ndocker compose exec postgres psql -U app -d app\n```\n\n## From a VPS\n\n```bash\nportta remote access open deploy@vps \\\n --project base-empresarial --service postgres\n# -> 127.0.0.1:55432\n```\n\nPoint the client at that. The bridge on the VPS binds *its* loopback and is\nnever published; the SSH tunnel is what carries it here. Works over Tailscale\nSSH with the same syntax.\n\n```bash\nportta remote access list\nportta remote access close <id>\n```\n\n## Every day, at a stable address\n\n```bash\nportta service publish --private \\\n --project base-empresarial --service postgres\n```\n\nA dedicated forwarder with a stable alias on the gateway's access network,\nreachable over the tailnet at the standard port. Each published service gets\nits own forwarder; project networks are never merged. See\n[Configure persistent Tailscale services](tailscale-services.md).\n\n## Several databases at once\n\nThat is the whole point, and it needs no special handling:\n\n```bash\nportta access open --project base-empresarial --service postgres # -> :33077\nportta access open --project base-eleicoes --service postgres # -> :33079\nportta access open --project issue-flow --service postgres # -> :33081\nportta access list\n```\n\nAll three still listen on 5432 inside their containers. None publishes it.\n\n## Migrations and seeds\n\nRun them where they have always run, inside the project:\n\n```bash\ndocker compose run --rm api npm run migrate\ndocker compose exec api php artisan migrate\n```\n\nThe gateway has no opinion about migrations and no access to your data. It\nnever runs one for you.\n\n## Backups\n\n```bash\nportta db psql --project base-empresarial -- -c '\\copy users to stdout csv' > users.csv\n```\n\nFor a full dump, use `pg_dump` inside the project so the file lands where you\nwant it:\n\n```bash\ndocker compose exec -T postgres pg_dump -U app app > backup.sql\n```\n\n## What not to do\n\n**Do not add `ports: [\"5432:5432\"]`** to get a database \"temporarily\" onto the\nhost. That is the port conflict this whole design removes, and it makes the\ndatabase reachable by everything else on the machine.\n\n**Do not point two worktrees at one database.** Let Compose create a volume per\nnamespace. Two environments writing to one database corrupt each other, and it\nis silent until it is not.\n\n**Do not publish a database on `0.0.0.0`.** `doctor` fails on it, and\n`service publish --public` refuses outright.\n\n## Reaching it by hostname instead\n\nIf the gateway has `PORTTA_TCP=true` and the project opted in, its\nPostgreSQL has a stable address that needs no bridge and no free port:\n\n```bash\npsql \"postgresql://demo@base-empresarial-postgres.localhost:5432/demo?sslmode=require\"\n```\n\nThe `sslmode` is not decoration: the hostname travels inside the TLS handshake,\nand without TLS there is nothing for the gateway to route on. MySQL cannot do\nthis at all. See [Configure TCP routing](tcp-routing.md).\n\nThe panel's Access page lists every address that applies to this host — LAN,\ntailnet, domain, loopback bridge — each with its scope. **Connect** reads the\ncontainer environment on demand and fills the connection string when the\nimage uses conventional variables (`POSTGRES_*`, …). The password is masked\nuntil you ask for it, and can be copied without being revealed. Opening that\npanel creates no route, bridge or published port.\n","text":"Connect to a database Short version, by situation. The reasoning is inOpen a TCP bridge. From the application Nothing changes and nothing is needed. The application reachespostgres:5432 over the project's own private network, exactly as it alwaysdid. From a GUI on this machine Host 127.0.0.1 Port the one printed User / password / database the project's own, from its .env — or from the panel's Connect panel, which reads the container environment on demand Add --local-port 55432 to keep a saved connection working across sessions.Otherwise the kernel picks a new free port each time, which is what lets fourdatabases be open at once. From the terminal, or from an agent Do not open a bridge. Run the client inside the project's network: Nothing is published, and the container is removed when you exit. Credentialscome from the target container's own environment. MySQL works the same way: Or, with no gateway involved at all: From a VPS Point the client at that. The bridge on the VPS binds its loopback and isnever published; the SSH tunnel is what carries it here. Works over TailscaleSSH with the same syntax. Every day, at a stable address A dedicated forwarder with a stable alias on the gateway's access network,reachable over the tailnet at the standard port. Each published service getsits own forwarder; project networks are never merged. SeeConfigure persistent Tailscale services. Several databases at once That is the whole point, and it needs no special handling: All three still listen on 5432 inside their containers. None publishes it. Migrations and seeds Run them where they have always run, inside the project: The gateway has no opinion about migrations and no access to your data. Itnever runs one for you. Backups For a full dump, use pg_dump inside the project so the file lands where youwant it: What not to do Do not add ports: [\"5432:5432\"] to get a database \"temporarily\" onto thehost. That is the port conflict this whole design removes, and it makes thedatabase reachable by everything else on the machine. Do not point two worktrees at one database. Let Compose create a volume pernamespace. Two environments writing to one database corrupt each other, and itis silent until it is not. Do not publish a database on 0.0.0.0. doctor fails on it, andservice publish --public refuses outright. Reaching it by hostname instead If the gateway has PORTTA_TCP=true and the project opted in, itsPostgreSQL has a stable address that needs no bridge and no free port: The sslmode is not decoration: the hostname travels inside the TLS handshake,and without TLS there is nothing for the gateway to route on. MySQL cannot dothis at all. See Configure TCP routing. The panel's Access page lists every address that applies to this host — LAN,tailnet, domain, loopback bridge — each with its scope. Connect reads thecontainer environment on demand and fills the connection string when theimage uses conventional variables (POSTGRES_*, …). The password is maskeduntil you ask for it, and can be copied without being revealed. Opening thatpanel creates no route, bridge or published port.","headings":[{"id":"connect-to-a-database","text":"Connect to a database","level":1,"line":0},{"id":"from-the-application","text":"From the application","level":2,"line":5},{"id":"from-a-gui-on-this-machine","text":"From a GUI on this machine","level":2,"line":11},{"id":"from-the-terminal-or-from-an-agent","text":"From the terminal, or from an agent","level":2,"line":33},{"id":"from-a-vps","text":"From a VPS","level":2,"line":58},{"id":"every-day-at-a-stable-address","text":"Every day, at a stable address","level":2,"line":75},{"id":"several-databases-at-once","text":"Several databases at once","level":2,"line":87},{"id":"migrations-and-seeds","text":"Migrations and seeds","level":2,"line":100},{"id":"backups","text":"Backups","level":2,"line":112},{"id":"what-not-to-do","text":"What not to do","level":2,"line":125},{"id":"reaching-it-by-hostname-instead","text":"Reaching it by hostname instead","level":2,"line":138}],"kind":"markdown"},{"slug":"tcp-access","title":"Open a TCP bridge","description":"Open temporary loopback bridges to private services.","source":"docs/product/guides/tcp-access.md","audience":"user","section":"Guides","category":"Databases and TCP services","url":"/docs/tcp-access","markdown":"# Open a TCP bridge\n\nHTTP is easy: Traefik reads the `Host` header and routes by hostname, so any\nnumber of web services share port 443.\n\nDatabases are not. This page explains why, and what the gateway does instead.\n\n\n> **There is now a second way.** With `PORTTA_TCP=true` the gateway can\n> tell databases apart by hostname on a single shared port, so a project's\n> Postgres is reachable at `<project>-postgres.<domain>:5432` without a bridge\n> at all. It works for PostgreSQL and Redis, not for MySQL, and it requires\n> TLS. See [Configure TCP routing](tcp-routing.md). Everything below still works, and\n> is still the answer for protocols that cannot do it. The panel lists every\n> address a datastore has on this host, and fills a connection string from\n> the container environment when it can do so honestly.\n\n## Why hostnames do not work for PostgreSQL\n\nRouting many services onto one port needs the connection to say which service\nit wants, *before* anything is proxied. HTTP does that in the request line.\nTLS does it in the SNI extension of the ClientHello.\n\nThe PostgreSQL, MySQL and Redis wire protocols do neither. A client connects\nand starts speaking the protocol; nothing in the opening bytes names a\ndestination. A proxy holding 5432 has no way to tell \"the demo-a database\" from\n\"the demo-b database\".\n\nConcretely:\n\n| | Carries a name a proxy can route on |\n|---|---|\n| HTTP | yes, in the `Host` header |\n| HTTPS / TLS | yes, in SNI, before decryption |\n| PostgreSQL | no (`sslmode=require` may add SNI, but not reliably, and not for a proxy that must not terminate TLS) |\n| MySQL | no |\n| Redis | no |\n| MongoDB | no |\n| SMTP | no |\n\n**Traefik TCP routers** exist and are useful, but they route on `HostSNI`,\nwhich means TLS. `HostSNI(\\`*\\`)` matches everything, so one entrypoint can\ncarry exactly one backend. That is a fine tool for a single persistent service\non a dedicated port; it is not a way to share 5432 between four databases.\n\nSo sharing one port between raw TCP services requires one of:\n\n- a **distinct port** per service,\n- a **distinct IP** per service (a Tailscale Service VIP, for instance),\n- a **tunnel** the client opens deliberately, or\n- a **protocol-aware proxy** that speaks PostgreSQL and can route on the\n startup packet's database name. That is a real thing (pgbouncer, pgcat), but\n a database-specific component, not general TCP.\n\nThe gateway takes the distinct-port route for sessions, and offers distinct\nidentities for the persistent case.\n\n## The four levels of access\n\n### A. Application to service\n\nUnchanged, and the gateway is not involved.\n\n```text\napi -> postgres:5432 on the project's own private network\n```\n\nKeep it that way. Nothing is published, and nothing else on the host can reach\nit.\n\n### B. A human on this machine\n\n```bash\nportta access open --project base-empresarial --service postgres\n```\n\n```text\n id a3f19c\n project base-empresarial\n service postgres\n target postgres:5432\n local 127.0.0.1:33077\n\n postgresql://<user>@127.0.0.1:33077/<database>\n```\n\nA small `socat` container joins the project's private network, forwards to\n`postgres:5432`, and publishes **127.0.0.1 on a port the kernel picks**. Open\none per database and they never collide, because the port that would collide,\n5432, is never published by anybody.\n\nIt touches nothing that belongs to the project: no volumes, no container\nchanges, no Compose edits. Closing it leaves no trace.\n\n```bash\nportta access list\nportta access inspect a3f19c\nportta access close a3f19c\nportta access close --project base-empresarial\nportta access gc # bridges whose target is gone\n```\n\n`--ttl 2h` expires a bridge; there is deliberately no default TTL, because a\nGUI client left open overnight is a normal thing to do.\n\n### C. An agent, or a quick query\n\nDo not open a bridge. Run the client inside the project's own network:\n\n```bash\nportta db psql --project base-empresarial -- -c 'select count(*) from users'\nportta redis cli --project base-empresarial -- keys 'session:*'\n```\n\nNo port is published, the container is removed on exit, and credentials are\nread from the target container's own environment and passed straight to the\nclient. They are never printed.\n\nEquivalent, with no gateway at all:\n\n```bash\ndocker compose exec postgres psql -U app -d app\n```\n\n### D. A service on a VPS\n\nThe bridge on the VPS binds *its* loopback, exactly as it does locally, and an\nSSH tunnel carries it to you:\n\n```bash\nportta remote access open deploy@vps --project base-empresarial --service postgres\n```\n\n```text\n remote base-empresarial/postgres:5432\n via deploy@vps\n local 127.0.0.1:55432\n```\n\n```text\nyour Mac -> SSH (over Tailscale, or plain) -> VPS 127.0.0.1:<bridge>\n -> the project's private network -> postgres\n```\n\nThe remote port is never published publicly. Host key verification stays on,\nand `ExitOnForwardFailure` means a lost port race is an error rather than a\ntunnel that silently forwards nothing.\n\n```bash\nportta remote access list\nportta remote access close <id>\n```\n\n## A persistent private address\n\nFor the database you connect to daily, a session bridge is friction. Publish it\ninstead:\n\n```bash\nportta service publish --private --project base-empresarial --service postgres\n```\n\nThis creates a **dedicated forwarder** for that one service, on that project's\nprivate network **and** the gateway's access network, with a stable alias.\n\n```text\nproject-a_default project-b_default\n postgres postgres\n | |\n forwarder-a-db forwarder-b-db\n | |\n +------- portta-access ----+\n |\n Tailscale\n```\n\nThe shape is the point. Each forwarder bridges exactly one service. Project\nnetworks are never merged with each other, and the Tailscale container is never\nattached to a project's network; it only ever sees the access network. Two\ndatabases can then keep port 5432 and be told apart by identity rather than by\nport.\n\n`portta doctor` fails if a forwarder ever ends up on the shared HTTP\nnetwork.\n\nThe tailnet side, meaning the Tailscale Service and the grants, is configured\non your tailnet, deliberately: the gateway never edits your Tailscale policy.\nIt prints exactly what to apply. See [Configure persistent Tailscale services](tailscale-services.md).\n\n## What is never published\n\nRefused, in every profile, regardless of flags:\n\n- PostgreSQL, MySQL, MariaDB, Redis, MongoDB, Memcached\n- OpenSearch and Elasticsearch\n- RabbitMQ and other brokers\n- the Docker API and the socket proxy\n\n`portta service publish --public` on any of those is an error, not a\nwarning. Bridges bind `127.0.0.1`; binding elsewhere requires `--bind` and\nprints a warning first, and `doctor` fails on a bridge bound beyond loopback.\n\n## UDP\n\nNot supported. The bridge forwards TCP only.\n\nThe pieces exist (Docker publishes UDP, Traefik has UDP entrypoints, Tailscale\ncarries UDP over the tailnet), but nothing here is implemented or tested for\nit, so it is listed as absent rather than as a caveat. If you need a UDP\nservice reachable, publish it from the project itself with an explicit\n`ports:` entry and bind it to loopback.\n\n## Credentials\n\nThe gateway never reads a project's `.env` to fill in a password, and never\nprints one. Connection strings it shows are templates:\n\n```text\npostgresql://<user>@127.0.0.1:33077/<database>\n```\n\nThe credentials are the project's, and stay there. The one exception is\n`db psql`, which reads them from the target container's environment and hands\nthem to the client process directly. They are not printed, logged, or written\nanywhere.\n\n## GUI clients\n\nTablePlus, DBeaver, DataGrip and friends all want a host and a port:\n\n| | |\n|---|---|\n| Host | `127.0.0.1` |\n| Port | whatever `access open` printed |\n| User / password / database | the project's own |\n\nRemote is identical: `remote access open` gives you a local address too, so\nthe client never needs to know the VPS exists.\n\nNote that the port changes each time you open a bridge, because the kernel\npicks a free one. Use `--local-port 55432` if you want a saved connection to\nkeep working:\n\n```bash\nportta access open --project base-empresarial --service postgres --local-port 55432\n```\n\n## Troubleshooting\n\n**\"cannot tell which port to forward\"** means the container exposes several\nports and its image is not a recognised datastore. Name it: `--port 5432`.\n\n**\"is on several networks; choose one\"** means the service is on more than one\nprivate network. Pick it with `--network <name>`.\n\n**The bridge exits immediately.** The target is not reachable from that\nnetwork on that port. `portta access inspect <id>` shows socat's own log.\n\n**The port changed.** It is meant to. Pin it with `--local-port`.\n\n**A bridge points nowhere after a `docker compose down`.** Run\n`portta access gc`.\n","text":"Open a TCP bridge HTTP is easy: Traefik reads the Host header and routes by hostname, so anynumber of web services share port 443. Databases are not. This page explains why, and what the gateway does instead. There is now a second way. With PORTTA_TCP=true the gateway cantell databases apart by hostname on a single shared port, so a project'sPostgres is reachable at <project>-postgres.<domain>:5432 without a bridgeat all. It works for PostgreSQL and Redis, not for MySQL, and it requiresTLS. See Configure TCP routing. Everything below still works, andis still the answer for protocols that cannot do it. The panel lists everyaddress a datastore has on this host, and fills a connection string fromthe container environment when it can do so honestly. Why hostnames do not work for PostgreSQL Routing many services onto one port needs the connection to say which serviceit wants, before anything is proxied. HTTP does that in the request line.TLS does it in the SNI extension of the ClientHello. The PostgreSQL, MySQL and Redis wire protocols do neither. A client connectsand starts speaking the protocol; nothing in the opening bytes names adestination. A proxy holding 5432 has no way to tell \"the demo-a database\" from\"the demo-b database\". Concretely: Carries a name a proxy can route on HTTP yes, in the Host header HTTPS / TLS yes, in SNI, before decryption PostgreSQL no (sslmode=require may add SNI, but not reliably, and not for a proxy that must not terminate TLS) MySQL no Redis no MongoDB no SMTP no Traefik TCP routers exist and are useful, but they route on HostSNI,which means TLS. HostSNI(\\*`)` matches everything, so one entrypoint cancarry exactly one backend. That is a fine tool for a single persistent serviceon a dedicated port; it is not a way to share 5432 between four databases. So sharing one port between raw TCP services requires one of: a distinct port per service, a distinct IP per service (a Tailscale Service VIP, for instance), a tunnel the client opens deliberately, or a protocol-aware proxy that speaks PostgreSQL and can route on thestartup packet's database name. That is a real thing (pgbouncer, pgcat), buta database-specific component, not general TCP. The gateway takes the distinct-port route for sessions, and offers distinctidentities for the persistent case. The four levels of access A. Application to service Unchanged, and the gateway is not involved. Keep it that way. Nothing is published, and nothing else on the host can reachit. B. A human on this machine A small socat container joins the project's private network, forwards topostgres:5432, and publishes 127.0.0.1 on a port the kernel picks. Openone per database and they never collide, because the port that would collide,5432, is never published by anybody. It touches nothing that belongs to the project: no volumes, no containerchanges, no Compose edits. Closing it leaves no trace. --ttl 2h expires a bridge; there is deliberately no default TTL, because aGUI client left open overnight is a normal thing to do. C. An agent, or a quick query Do not open a bridge. Run the client inside the project's own network: No port is published, the container is removed on exit, and credentials areread from the target container's own environment and passed straight to theclient. They are never printed. Equivalent, with no gateway at all: D. A service on a VPS The bridge on the VPS binds its loopback, exactly as it does locally, and anSSH tunnel carries it to you: The remote port is never published publicly. Host key verification stays on,and ExitOnForwardFailure means a lost port race is an error rather than atunnel that silently forwards nothing. A persistent private address For the database you connect to daily, a session bridge is friction. Publish itinstead: This creates a dedicated forwarder for that one service, on that project'sprivate network and the gateway's access network, with a stable alias. The shape is the point. Each forwarder bridges exactly one service. Projectnetworks are never merged with each other, and the Tailscale container is neverattached to a project's network; it only ever sees the access network. Twodatabases can then keep port 5432 and be told apart by identity rather than byport. portta doctor fails if a forwarder ever ends up on the shared HTTPnetwork. The tailnet side, meaning the Tailscale Service and the grants, is configuredon your tailnet, deliberately: the gateway never edits your Tailscale policy.It prints exactly what to apply. See Configure persistent Tailscale services. What is never published Refused, in every profile, regardless of flags: PostgreSQL, MySQL, MariaDB, Redis, MongoDB, Memcached OpenSearch and Elasticsearch RabbitMQ and other brokers the Docker API and the socket proxy portta service publish --public on any of those is an error, not awarning. Bridges bind 127.0.0.1; binding elsewhere requires --bind andprints a warning first, and doctor fails on a bridge bound beyond loopback. UDP Not supported. The bridge forwards TCP only. The pieces exist (Docker publishes UDP, Traefik has UDP entrypoints, Tailscalecarries UDP over the tailnet), but nothing here is implemented or tested forit, so it is listed as absent rather than as a caveat. If you need a UDPservice reachable, publish it from the project itself with an explicitports: entry and bind it to loopback. Credentials The gateway never reads a project's .env to fill in a password, and neverprints one. Connection strings it shows are templates: The credentials are the project's, and stay there. The one exception isdb psql, which reads them from the target container's environment and handsthem to the client process directly. They are not printed, logged, or writtenanywhere. GUI clients TablePlus, DBeaver, DataGrip and friends all want a host and a port: Host 127.0.0.1 Port whatever access open printed User / password / database the project's own Remote is identical: remote access open gives you a local address too, sothe client never needs to know the VPS exists. Note that the port changes each time you open a bridge, because the kernelpicks a free one. Use --local-port 55432 if you want a saved connection tokeep working: Troubleshooting \"cannot tell which port to forward\" means the container exposes severalports and its image is not a recognised datastore. Name it: --port 5432. \"is on several networks; choose one\" means the service is on more than oneprivate network. Pick it with --network <name>. The bridge exits immediately. The target is not reachable from thatnetwork on that port. portta access inspect <id> shows socat's own log. The port changed. It is meant to. Pin it with --local-port. A bridge points nowhere after a docker compose down. Runportta access gc.","headings":[{"id":"open-a-tcp-bridge","text":"Open a TCP bridge","level":1,"line":0},{"id":"why-hostnames-do-not-work-for-postgresql","text":"Why hostnames do not work for PostgreSQL","level":2,"line":17},{"id":"the-four-levels-of-access","text":"The four levels of access","level":2,"line":57},{"id":"a-application-to-service","text":"A. Application to service","level":3,"line":59},{"id":"b-a-human-on-this-machine","text":"B. A human on this machine","level":3,"line":70},{"id":"c-an-agent-or-a-quick-query","text":"C. An agent, or a quick query","level":3,"line":105},{"id":"d-a-service-on-a-vps","text":"D. A service on a VPS","level":3,"line":124},{"id":"a-persistent-private-address","text":"A persistent private address","level":2,"line":153},{"id":"what-is-never-published","text":"What is never published","level":2,"line":189},{"id":"udp","text":"UDP","level":2,"line":202},{"id":"credentials","text":"Credentials","level":2,"line":212},{"id":"gui-clients","text":"GUI clients","level":2,"line":226},{"id":"troubleshooting","text":"Troubleshooting","level":2,"line":247}],"kind":"markdown"},{"slug":"tcp-routing","title":"Configure TCP routing","description":"Route supported TCP protocols by hostname.","source":"docs/product/guides/tcp-routing.md","audience":"user","section":"Guides","category":"Databases and TCP services","url":"/docs/tcp-routing","markdown":"# Configure TCP routing\n\nOne host port per protocol, any number of instances behind it, told apart by\nthe hostname the client asks for:\n\n```text\nstorefront-postgres.localhost:5432 -> storefront's postgres:5432\ncheckout-postgres.localhost:5432 -> checkout's postgres:5432\nstorefront-redis.localhost:6379 -> storefront's redis:6379\n```\n\nNo project renumbers a port. No container publishes one. Traefik owns `:5432`\nand `:6379` on the host and picks the backend from the TLS Server Name\nIndication.\n\nThis document records what was verified, what was rejected, and why. The\nmatrix at the top is the short answer.\n\n---\n\n## The matrix\n\nEvery cell was verified against Traefik v3.7.12 with real clients, not inferred\nfrom documentation. The lab that produced these results is in\n[Reproducing the findings](#reproducing-the-findings).\n\n| Protocol | Port | Same IP and port, told apart by hostname | Strategy | TLS | Local | VPS |\n|---|---:|---|---|---|---|---|\n| PostgreSQL | 5432 | **Yes** | STARTTLS then SNI, TLS terminated at Traefik | Required, `sslmode=require` | Yes | Yes |\n| Redis / Valkey | 6379 | **Yes**, with an explicit `--sni` | TLS-first, SNI, terminated at Traefik | Required, and the client must be told the name | Yes | Yes |\n| MySQL | 3306 | **No** | Not possible with Traefik. Falls back to a loopback bridge | n/a | Bridge | Bridge |\n| MariaDB | 3306 | **No** | Same as MySQL | n/a | Bridge | Bridge |\n| MongoDB | 27017 | Not evaluated | TLS-first, likely feasible | Would be required | - | - |\n| Elasticsearch / OpenSearch | 9200 | Not evaluated, and unnecessary | It speaks HTTP, so the existing HTTP router already does this | - | - | - |\n| Memcached, AMQP, MQTT, SMTP | various | Not evaluated | Each needs its own answer | - | - | - |\n\n\"Not evaluated\" means exactly that. Nothing here was marked possible without a\nclient actually connecting to the right instance.\n\n---\n\n## Why PostgreSQL works\n\nPostgreSQL negotiates TLS with STARTTLS: the client opens a plain connection\nand sends an `SSLRequest`, the server answers `S`, and only then does the TLS\nhandshake begin. Traefik implements this. From the official documentation:\n\n> Traefik supports the Postgres STARTTLS protocol. To do so, Traefik reads the\n> first bytes sent by a Postgres client, identifies if they correspond to the\n> message of a STARTTLS negotiation, and, if so, acknowledges and signals the\n> client that it can start the TLS handshake.\n\nThe TLS handshake carries the Server Name Indication, and `HostSNI` matches on\nit. libpq sets SNI by default since PostgreSQL 14 (`sslsni`, default `1`), so\nno client configuration is needed beyond asking for TLS at all.\n\nTwo instances, both listening on 5432 inside their own containers, neither\npublishing a host port, both reached through `127.0.0.1:15432`:\n\n```console\n$ psql \"postgresql://demo@alpha-postgres.dgtest.localhost:15432/demo?sslmode=require\" -tAc 'select name from whoami'\nALPHA\n$ psql \"postgresql://demo@beta-postgres.dgtest.localhost:15432/demo?sslmode=require\" -tAc 'select name from whoami'\nBETA\n```\n\n### The part the documentation does not mention: ALPN\n\nThe first attempt failed, on both instances:\n\n```text\npsql: error: ... SSL error: tlsv1 alert no application protocol\n```\n\nPostgreSQL 17 registered `postgresql` as an ALPN protocol identifier when it\nadded direct TLS negotiation, and libpq 17 and later offer it on every TLS\nconnection, STARTTLS included. Traefik's entrypoints advertise `h2`,\n`http/1.1` and `acme-tls/1`, none of which the client offers, so the handshake\nis rejected before any Postgres traffic happens.\n\nThe fix is a TLS option, and it belongs in the dynamic configuration:\n\n```yaml\ntls:\n options:\n postgres:\n alpnProtocols:\n - postgresql\n```\n\nreferenced from the router with `traefik.tcp.routers.<name>.tls.options=postgres@file`.\n\nClients that send no ALPN at all are unaffected: libpq 16 connects through the\nsame entrypoint without complaint. So one option serves both.\n\n---\n\n## Why MySQL does not work, and what happens instead\n\nThe MySQL connection phase starts with the **server**:\n\n> It starts with the client connect()ing to the server which may send a ERR\n> packet and finish the handshake or send a Initial Handshake Packet which the\n> client answers with a Handshake Response Packet.\n\nTLS, when used, comes later: server `Protocol::Handshake`, client\n`Protocol::SSLRequest`, then the TLS exchange. The client cannot open with a\nClientHello, so there is no SNI to route on at the moment a proxy must choose a\nbackend. A proxy could only get one by impersonating the server's greeting\nfirst, which means speaking the MySQL protocol. Traefik does not, and should\nnot.\n\nOn the wire, opening a socket to MariaDB and sending nothing at all:\n\n```console\n$ nc -w 2 my-a 3306 | od -c | head -2\n0000000 Z \\0 \\0 \\0 \\n 1 1 . 4 . 9 - M a r i\n0000020 a D B - u b u 2 4 0 4 \\0 003 \\0 \\0 \\0\n```\n\nThe server has already spoken. Behind a `HostSNI` router the two sides then\nwait for each other until the client gives up:\n\n```console\n$ mariadb -h alpha-mysql.dgtest.localhost -P 13306 -u root -p --ssl -e 'select 1'\nERROR 2013 (HY000): Lost connection to server at\n'handshake: reading initial communication packet', system error: 11\n```\n\nThe same client against the container directly answers immediately, so this is\nthe routing layer, not the database.\n\nRejected rather than worked around:\n\n- **Fake the server greeting inside Traefik.** Would require a MySQL-aware\n handler upstream. Not our call to make, and not something to shim.\n- **ProxySQL or MySQL Router in front.** Both do route by user or schema, not\n by hostname, and both add a component with its own configuration, users and\n failure modes to a tool whose entire point is that it stays small.\n- **Route by username, `user@project`.** Changes every connection string in\n every project and breaks the moment two projects share a username.\n- **A second IP per instance.** Works, and pushes host networking complexity\n onto the user on macOS, where it is worst.\n\nMySQL keeps the mechanism the gateway already has: a loopback bridge on a port\nthe kernel picks, opened when you need it (`portta access open`, or the\nAccess page). That is not a regression. It is what every protocol had before,\nand it still works for every protocol.\n\n---\n\n## Redis: it works, with one wart\n\nRedis has no STARTTLS. A TLS client sends a ClientHello as the first bytes,\nwhich is the easy case for SNI routing. Verified with two instances on one\nport:\n\n```console\n$ redis-cli -h 127.0.0.1 -p 16379 --tls --sni alpha-redis.dgtest.localhost --cacert ca.crt get whoami\n\"ALPHA\"\n$ redis-cli -h 127.0.0.1 -p 16379 --tls --sni beta-redis.dgtest.localhost --cacert ca.crt get whoami\n\"BETA\"\n```\n\nThe wart: `redis-cli` does **not** derive SNI from `-h`. Connecting with\n`-h alpha-redis.dgtest.localhost --tls` and no `--sni` sends no SNI and fails.\nThe flag is mandatory, and the panel prints the whole command rather than\nleaving anyone to discover this.\n\nMost libraries do better, because they set SNI from the host they were given:\nnode-redis and ioredis through `tls.servername`, redis-py through its SSL\ncontext. Verify yours before relying on it.\n\nBackends are untouched: Redis needs no `tls-port`, no certificate and no\nconfiguration change, because Traefik terminates TLS and speaks plain RESP to\nthe container. A project that never opts in keeps working exactly as it does\ntoday.\n\n---\n\n## Termination, not passthrough\n\nTraefik can forward the encrypted stream untouched (`tls.passthrough=true`) or\nterminate TLS itself and speak plaintext to the backend. The gateway\nterminates.\n\nPassthrough would mean every project's Postgres and Redis needs its own\ncertificate, its own `ssl = on`, its own renewal. That is a large, permanent\nchange to consumer projects, and this gateway's first principle is that a\nproject adds an overlay of labels and networks and nothing else\n([ADR 0001](../../development/adr/0001-decoupled-infrastructure.md)).\n\nTerminating gives:\n\n- containers unchanged, no TLS configuration anywhere but the gateway;\n- one certificate, the wildcard the gateway already issues;\n- plaintext only ever on the Docker network between Traefik and the container,\n which is the same trust boundary the HTTP routers already use.\n\nThe cost is honest: the gateway sees the traffic. On a workstation it already\nruns everything; on a VPS it is the component that terminates HTTPS anyway.\n\n---\n\n## Naming, and why it is flat\n\nThe obvious shape is `{service}.{project}.{domain}`:\n\n```text\npostgres.storefront.dev.example.com\n```\n\nIt does not survive certificate validation. A wildcard certificate covers\nexactly one label, so `*.dev.example.com` does not match a name with two labels\nin front of it. Verified, with a router in place for that exact name so only\nthe certificate was in question:\n\n```console\n$ psql \"postgresql://demo@postgres.alpha.dgtest.localhost:15432/demo?sslmode=require\"\nALPHA # routes fine, nothing is verified\n\n$ psql \"...postgres.alpha.dgtest.localhost...?sslmode=verify-full&sslrootcert=ca.crt\"\npsql: error: server certificate for \"*.dgtest.localhost\" (and 1 other name)\ndoes not match host name \"postgres.alpha.dgtest.localhost\"\n```\n\nCovering it would need a certificate per project, issued and renewed as\nprojects appear. On a VPS that is ACME traffic per project; locally it is a\nlocal CA that has to be re-run.\n\nSo the convention is flat, and it is the one the gateway already uses for HTTP\n([ADR 0005](../../development/adr/0005-hostname-convention.md)):\n\n```text\n<compose-project>-<service>.<domain>\n\nstorefront-postgres.localhost\nstorefront-redis.localhost\ncheckout-postgres.vpn.example.com\n```\n\nOne wildcard covers every service of every project, HTTP and TCP alike, and\n`portta tls init` already issues exactly that\n(`subjectAltName=DNS:*.$DOMAIN,DNS:$DOMAIN`). Nobody invents a hostname: the\ngateway derives it from the labels Compose already sets.\n\n---\n\n## Networks: datastores still do not join the HTTP network\n\nThe gateway's shared `portta` network carries HTTP services. Databases do not\nbelong on it; Compose validation and routing tests reject that attachment.\n\nTCP routing does not change that. A datastore that opts into hostname routing\njoins **`portta-access`**, the network that already exists for reaching\nprivate TCP services, and Traefik joins it too:\n\n```text\nportta HTTP services <- Traefik, web, api\nportta-access opted-in TCP services <- Traefik, postgres, redis\nportta-control the socket proxy <- Traefik only, internal\n<project>_default everything else <- Traefik has no route\n```\n\nA database that does not opt in is on its project network and nothing else,\nexactly as before, reachable only through a bridge.\n\n---\n\n## Exposure\n\nBeing visible to the gateway is not being published. Three things have to line\nup before a database answers on a host port:\n\n1. `providers.docker.exposedByDefault` stays `false`, so a container is routed\n only when it carries `traefik.enable=true`;\n2. the project's overlay has to add the TCP router labels and join the access\n network, which is a deliberate edit in the project's own repository;\n3. the gateway has to have the TCP entrypoints enabled at all, which is\n `PORTTA_TCP=true` and off by default.\n\nWhere the entrypoints listen follows the profile, the same as everything else:\n\n| Profile | Bind | Who can reach a database |\n|---|---|---|\n| `local` | `127.0.0.1` | this machine |\n| `remote-private` with Tailscale | the tailnet address | your tailnet, subject to its ACLs |\n| `remote-private` without Tailscale | `PORTTA_BIND_ADDRESS` | whoever can reach that interface |\n| `remote-public` | **refused** | nobody: the gateway will not start TCP entrypoints on a public profile |\n\nThe last row is a hard refusal, not a warning. `public enable` is about HTTP\nservices that opted in; a database is never part of that, and\n`service publish --public` has always been refused for datastores. TCP\nentrypoints keep the same rule.\n\nCredentials are unaffected: the gateway routes bytes and never reads a\nproject's `.env`. Authentication stays PostgreSQL's and Redis's own.\n\n### What a hostname that matches nothing gets\n\nNot a closed connection. A Traefik entrypoint serves HTTP as well as TCP, and\nwhen no TCP router matches the SNI the connection falls through to the HTTP\nside, which answers `HTTP/1.1 404 Not Found`. Verified:\n\n```text\n$ printf 'GET / HTTP/1.0\\r\\n\\r\\n' \\\n | openssl s_client -connect 127.0.0.1:5432 -servername nobody.localhost -quiet\nHTTP/1.0 404 Not Found\n```\n\nNo database is reached, so this is not a security hole. It is a diagnostic\none: the client reports whatever it makes of an HTTP response rather than\n\"unknown host\", and the message names neither the hostname nor Traefik.\n\n```text\n$ redis-cli -h 127.0.0.1 -p 6379 --tls --sni typo-redis.localhost get k\nError: Protocol error, got \"H\" as reply type byte\n```\n\n`H` is the first byte of `HTTP`. Read that error as *the hostname matched no\nrouter* — a typo, a project that is not running, or a container whose route\nTraefik has not picked up yet. `portta urls` and the panel's Access page\nshow the hostnames that do exist.\n\n---\n\n## Local, on macOS with Docker Desktop or OrbStack\n\nNothing extra is needed, which is the point.\n\n- **DNS.** `*.localhost` resolves to loopback at any depth on macOS, verified\n for `a.localhost`, `a.b.localhost` and\n `storefront-postgres.dgtest.localhost`. No `/etc/hosts`, no dnsmasq, no\n resolver file.\n- **Ports.** Traefik publishes `127.0.0.1:5432` and `127.0.0.1:6379`. The VM\n boundary is irrelevant: it is an ordinary published port, the same mechanism\n the gateway already uses for 80 and 443.\n- **Certificates.** `portta tls init` issues a local CA and a wildcard for\n the domain. `sslmode=require` needs no trust at all; `verify-full` needs the\n CA, which `portta tls trust` explains how to install. `mkcert` is not\n required and would only duplicate what is there.\n- **Conflict.** If something already holds 5432 on the host, the entrypoint\n will not bind. `portta doctor` reports it, and the ports are\n configurable.\n\n```bash\npsql \"postgresql://demo@storefront-postgres.localhost:5432/demo?sslmode=require\"\n```\n\n---\n\n## Remote, on Debian or Ubuntu\n\nSame mechanism, different exposure.\n\n- **DNS.** The wildcard record that already points at the host covers these\n names, because they are the same flat namespace as the HTTP ones.\n- **Certificates.** The existing ACME DNS-01 wildcard covers them too. HTTP-01\n cannot issue a wildcard, which is why the gateway already uses DNS-01.\n- **Tailscale.** With `TAILSCALE_ENABLED=true` Traefik runs inside the Tailscale\n container's network namespace ([ADR 0007](../../development/adr/0007-tailscale-sidecar.md)), so\n the TCP entrypoints listen on the tailnet and nowhere else. This is the\n intended way to reach a remote database.\n- **Firewall.** Nothing needs opening for the Tailscale path. Without it, the\n bind address is an interface you choose, and Docker's published ports bypass\n UFW ([Configure firewall rules](firewall.md)).\n- **Cloudflare.** DNS only. Cloudflare's HTTP proxy does not forward PostgreSQL\n or Redis, and turning the orange cloud on for these records breaks them\n rather than protecting them. Spectrum is a paid product for arbitrary TCP and\n is out of scope here.\n\n---\n\n## When SNI is not there: the fallback\n\nThree cases produce no SNI, and all three fail the same way. Traefik finds no\nmatching TCP router and hands the connection to its HTTP muxer, which answers\n`HTTP/1.1 400 Bad Request`. The client reports that first byte:\n\n```text\npsql: expected authentication request from server, but received H\nredis-cli: Protocol error, got \"H\" as reply type byte\n```\n\n| Cause | Fix |\n|---|---|\n| `sslmode=disable`, or any non-TLS connection | ask for TLS: `sslmode=require` |\n| connecting to an IP rather than a name (RFC 6066 forbids SNI for literal IPs) | use the hostname |\n| `redis-cli --tls` without `--sni` | pass `--sni <host>` |\n\nAnd when hostname routing does not apply at all, MySQL above all, the gateway\nfalls back to what it already does well: a bridge on a free loopback port,\nopened on demand and closed when you are done. Both mechanisms coexist. A\nproject can be reached by hostname and by bridge on the same day.\n\n---\n\n## Impact\n\nPer-connection cost of the extra hop, measured from the same Docker network\nwith the client's own startup subtracted, 40 connect-and-query cycles, best of\ntwo runs:\n\n| Path | Per connection |\n|---|---:|\n| straight to the container, plaintext | 1.6 ms |\n| through Traefik, TLS terminated, SNI routed | 3.2 ms |\n\nAbout 1.6 ms, most of it the TLS handshake rather than the proxying. It is paid\non connect, not per query, so a pooled application pays it once per pool\nmember and never notices; a script that opens a connection per statement will.\n\nWorth knowing before relying on it:\n\n- **Reloads.** Traefik applies configuration changes to new connections.\n Established TCP connections are not cut when an unrelated container starts,\n but a router that disappears takes its connections with it.\n- **Pooling.** Nothing here interferes with PgBouncer or a client-side pool.\n A pool in front of the gateway, or behind it, both work.\n- **Timeouts.** Traefik's TCP timeouts apply. A long-idle psql session behaves\n the same as it does against a bridge.\n- **Observability.** `portta logs traefik` shows router matching. There is\n no per-query visibility and there should not be.\n\n---\n\n## Adding a protocol\n\nProtocol knowledge lives in one registry rather than scattered through\nport-number conditionals, and each entry states what was verified:\n\n```text\npostgres 5432 routing: starttls-sni tls: terminate alpn: postgresql\nredis 6379 routing: tls-sni tls: terminate client must send SNI explicitly\nmysql 3306 routing: unsupported fallback: bridge\n```\n\nTo add one:\n\n1. establish whether the client sends a TLS ClientHello, with SNI, before the\n server sends anything. If the server speaks first, the answer is no unless\n Traefik has explicit support for that protocol's STARTTLS;\n2. check whether the client offers an ALPN protocol, and add it to the TLS\n options if so. This is what broke PostgreSQL first;\n3. add the entry to the registry with its default port and strategy;\n4. add an integration test with **two** instances and distinct data. One\n instance proves nothing: it would pass with the routing removed entirely.\n\nNever assume a TCP service supports SNI because another one does.\n\n---\n\n## Reproducing the findings\n\nThe lab is two PostgreSQL and two Redis containers, none publishing a port,\nbehind one Traefik with a TCP entrypoint per protocol, and a local CA. The\nintegration test `tests/e2e/tcp-routing.test.sh` is that lab, automated, and it\nis what keeps these answers true.\n\n```bash\nnpm run test:e2e -- --suite tcp-routing # isolated Docker host\n```\n\nThe questions it answers, in order: two instances on one port told apart by\nhostname, distinct data proving the route, TLS required, a connection with no\nSNI refused, a container restarted and still reachable, a container removed and\nits route gone, and Traefik restarted with both routes back.\n","text":"Configure TCP routing One host port per protocol, any number of instances behind it, told apart bythe hostname the client asks for: No project renumbers a port. No container publishes one. Traefik owns :5432and :6379 on the host and picks the backend from the TLS Server NameIndication. This document records what was verified, what was rejected, and why. Thematrix at the top is the short answer. The matrix Every cell was verified against Traefik v3.7.12 with real clients, not inferredfrom documentation. The lab that produced these results is inReproducing the findings. Protocol Port Same IP and port, told apart by hostname Strategy TLS Local VPS PostgreSQL 5432 Yes STARTTLS then SNI, TLS terminated at Traefik Required, sslmode=require Yes Yes Redis / Valkey 6379 Yes, with an explicit --sni TLS-first, SNI, terminated at Traefik Required, and the client must be told the name Yes Yes MySQL 3306 No Not possible with Traefik. Falls back to a loopback bridge n/a Bridge Bridge MariaDB 3306 No Same as MySQL n/a Bridge Bridge MongoDB 27017 Not evaluated TLS-first, likely feasible Would be required - - Elasticsearch / OpenSearch 9200 Not evaluated, and unnecessary It speaks HTTP, so the existing HTTP router already does this - - - Memcached, AMQP, MQTT, SMTP various Not evaluated Each needs its own answer - - - \"Not evaluated\" means exactly that. Nothing here was marked possible without aclient actually connecting to the right instance. Why PostgreSQL works PostgreSQL negotiates TLS with STARTTLS: the client opens a plain connectionand sends an SSLRequest, the server answers S, and only then does the TLShandshake begin. Traefik implements this. From the official documentation: Traefik supports the Postgres STARTTLS protocol. To do so, Traefik reads thefirst bytes sent by a Postgres client, identifies if they correspond to themessage of a STARTTLS negotiation, and, if so, acknowledges and signals theclient that it can start the TLS handshake. The TLS handshake carries the Server Name Indication, and HostSNI matches onit. libpq sets SNI by default since PostgreSQL 14 (sslsni, default 1), sono client configuration is needed beyond asking for TLS at all. Two instances, both listening on 5432 inside their own containers, neitherpublishing a host port, both reached through 127.0.0.1:15432: The part the documentation does not mention: ALPN The first attempt failed, on both instances: PostgreSQL 17 registered postgresql as an ALPN protocol identifier when itadded direct TLS negotiation, and libpq 17 and later offer it on every TLSconnection, STARTTLS included. Traefik's entrypoints advertise h2,http/1.1 and acme-tls/1, none of which the client offers, so the handshakeis rejected before any Postgres traffic happens. The fix is a TLS option, and it belongs in the dynamic configuration: referenced from the router with traefik.tcp.routers.<name>.tls.options=postgres@file. Clients that send no ALPN at all are unaffected: libpq 16 connects through thesame entrypoint without complaint. So one option serves both. Why MySQL does not work, and what happens instead The MySQL connection phase starts with the server: It starts with the client connect()ing to the server which may send a ERRpacket and finish the handshake or send a Initial Handshake Packet which theclient answers with a Handshake Response Packet. TLS, when used, comes later: server Protocol::Handshake, clientProtocol::SSLRequest, then the TLS exchange. The client cannot open with aClientHello, so there is no SNI to route on at the moment a proxy must choose abackend. A proxy could only get one by impersonating the server's greetingfirst, which means speaking the MySQL protocol. Traefik does not, and shouldnot. On the wire, opening a socket to MariaDB and sending nothing at all: The server has already spoken. Behind a HostSNI router the two sides thenwait for each other until the client gives up: The same client against the container directly answers immediately, so this isthe routing layer, not the database. Rejected rather than worked around: Fake the server greeting inside Traefik. Would require a MySQL-awarehandler upstream. Not our call to make, and not something to shim. ProxySQL or MySQL Router in front. Both do route by user or schema, notby hostname, and both add a component with its own configuration, users andfailure modes to a tool whose entire point is that it stays small. Route by username, user@project. Changes every connection string inevery project and breaks the moment two projects share a username. A second IP per instance. Works, and pushes host networking complexityonto the user on macOS, where it is worst. MySQL keeps the mechanism the gateway already has: a loopback bridge on a portthe kernel picks, opened when you need it (portta access open, or theAccess page). That is not a regression. It is what every protocol had before,and it still works for every protocol. Redis: it works, with one wart Redis has no STARTTLS. A TLS client sends a ClientHello as the first bytes,which is the easy case for SNI routing. Verified with two instances on oneport: The wart: redis-cli does not derive SNI from -h. Connecting with-h alpha-redis.dgtest.localhost --tls and no --sni sends no SNI and fails.The flag is mandatory, and the panel prints the whole command rather thanleaving anyone to discover this. Most libraries do better, because they set SNI from the host they were given:node-redis and ioredis through tls.servername, redis-py through its SSLcontext. Verify yours before relying on it. Backends are untouched: Redis needs no tls-port, no certificate and noconfiguration change, because Traefik terminates TLS and speaks plain RESP tothe container. A project that never opts in keeps working exactly as it doestoday. Termination, not passthrough Traefik can forward the encrypted stream untouched (tls.passthrough=true) orterminate TLS itself and speak plaintext to the backend. The gatewayterminates. Passthrough would mean every project's Postgres and Redis needs its owncertificate, its own ssl = on, its own renewal. That is a large, permanentchange to consumer projects, and this gateway's first principle is that aproject adds an overlay of labels and networks and nothing else(ADR 0001). Terminating gives: containers unchanged, no TLS configuration anywhere but the gateway; one certificate, the wildcard the gateway already issues; plaintext only ever on the Docker network between Traefik and the container,which is the same trust boundary the HTTP routers already use. The cost is honest: the gateway sees the traffic. On a workstation it alreadyruns everything; on a VPS it is the component that terminates HTTPS anyway. Naming, and why it is flat The obvious shape is {service}.{project}.{domain}: It does not survive certificate validation. A wildcard certificate coversexactly one label, so *.dev.example.com does not match a name with two labelsin front of it. Verified, with a router in place for that exact name so onlythe certificate was in question: Covering it would need a certificate per project, issued and renewed asprojects appear. On a VPS that is ACME traffic per project; locally it is alocal CA that has to be re-run. So the convention is flat, and it is the one the gateway already uses for HTTP(ADR 0005): One wildcard covers every service of every project, HTTP and TCP alike, andportta tls init already issues exactly that(subjectAltName=DNS:*.$DOMAIN,DNS:$DOMAIN). Nobody invents a hostname: thegateway derives it from the labels Compose already sets. Networks: datastores still do not join the HTTP network The gateway's shared portta network carries HTTP services. Databases do notbelong on it; Compose validation and routing tests reject that attachment. TCP routing does not change that. A datastore that opts into hostname routingjoins portta-access, the network that already exists for reachingprivate TCP services, and Traefik joins it too: A database that does not opt in is on its project network and nothing else,exactly as before, reachable only through a bridge. Exposure Being visible to the gateway is not being published. Three things have to lineup before a database answers on a host port: providers.docker.exposedByDefault stays false, so a container is routedonly when it carries traefik.enable=true; the project's overlay has to add the TCP router labels and join the accessnetwork, which is a deliberate edit in the project's own repository; the gateway has to have the TCP entrypoints enabled at all, which isPORTTA_TCP=true and off by default. Where the entrypoints listen follows the profile, the same as everything else: Profile Bind Who can reach a database local 127.0.0.1 this machine remote-private with Tailscale the tailnet address your tailnet, subject to its ACLs remote-private without Tailscale PORTTA_BIND_ADDRESS whoever can reach that interface remote-public refused nobody: the gateway will not start TCP entrypoints on a public profile The last row is a hard refusal, not a warning. public enable is about HTTPservices that opted in; a database is never part of that, andservice publish --public has always been refused for datastores. TCPentrypoints keep the same rule. Credentials are unaffected: the gateway routes bytes and never reads aproject's .env. Authentication stays PostgreSQL's and Redis's own. What a hostname that matches nothing gets Not a closed connection. A Traefik entrypoint serves HTTP as well as TCP, andwhen no TCP router matches the SNI the connection falls through to the HTTPside, which answers HTTP/1.1 404 Not Found. Verified: No database is reached, so this is not a security hole. It is a diagnosticone: the client reports whatever it makes of an HTTP response rather than\"unknown host\", and the message names neither the hostname nor Traefik. H is the first byte of HTTP. Read that error as the hostname matched norouter — a typo, a project that is not running, or a container whose routeTraefik has not picked up yet. portta urls and the panel's Access pageshow the hostnames that do exist. Local, on macOS with Docker Desktop or OrbStack Nothing extra is needed, which is the point. DNS. *.localhost resolves to loopback at any depth on macOS, verifiedfor a.localhost, a.b.localhost andstorefront-postgres.dgtest.localhost. No /etc/hosts, no dnsmasq, noresolver file. Ports. Traefik publishes 127.0.0.1:5432 and 127.0.0.1:6379. The VMboundary is irrelevant: it is an ordinary published port, the same mechanismthe gateway already uses for 80 and 443. Certificates. portta tls init issues a local CA and a wildcard forthe domain. sslmode=require needs no trust at all; verify-full needs theCA, which portta tls trust explains how to install. mkcert is notrequired and would only duplicate what is there. Conflict. If something already holds 5432 on the host, the entrypointwill not bind. portta doctor reports it, and the ports areconfigurable. Remote, on Debian or Ubuntu Same mechanism, different exposure. DNS. The wildcard record that already points at the host covers thesenames, because they are the same flat namespace as the HTTP ones. Certificates. The existing ACME DNS-01 wildcard covers them too. HTTP-01cannot issue a wildcard, which is why the gateway already uses DNS-01. Tailscale. With TAILSCALE_ENABLED=true Traefik runs inside the Tailscalecontainer's network namespace (ADR 0007), sothe TCP entrypoints listen on the tailnet and nowhere else. This is theintended way to reach a remote database. Firewall. Nothing needs opening for the Tailscale path. Without it, thebind address is an interface you choose, and Docker's published ports bypassUFW (Configure firewall rules). Cloudflare. DNS only. Cloudflare's HTTP proxy does not forward PostgreSQLor Redis, and turning the orange cloud on for these records breaks themrather than protecting them. Spectrum is a paid product for arbitrary TCP andis out of scope here. When SNI is not there: the fallback Three cases produce no SNI, and all three fail the same way. Traefik finds nomatching TCP router and hands the connection to its HTTP muxer, which answersHTTP/1.1 400 Bad Request. The client reports that first byte: Cause Fix sslmode=disable, or any non-TLS connection ask for TLS: sslmode=require connecting to an IP rather than a name (RFC 6066 forbids SNI for literal IPs) use the hostname redis-cli --tls without --sni pass --sni <host> And when hostname routing does not apply at all, MySQL above all, the gatewayfalls back to what it already does well: a bridge on a free loopback port,opened on demand and closed when you are done. Both mechanisms coexist. Aproject can be reached by hostname and by bridge on the same day. Impact Per-connection cost of the extra hop, measured from the same Docker networkwith the client's own startup subtracted, 40 connect-and-query cycles, best oftwo runs: Path Per connection straight to the container, plaintext 1.6 ms through Traefik, TLS terminated, SNI routed 3.2 ms About 1.6 ms, most of it the TLS handshake rather than the proxying. It is paidon connect, not per query, so a pooled application pays it once per poolmember and never notices; a script that opens a connection per statement will. Worth knowing before relying on it: Reloads. Traefik applies configuration changes to new connections.Established TCP connections are not cut when an unrelated container starts,but a router that disappears takes its connections with it. Pooling. Nothing here interferes with PgBouncer or a client-side pool.A pool in front of the gateway, or behind it, both work. Timeouts. Traefik's TCP timeouts apply. A long-idle psql session behavesthe same as it does against a bridge. Observability. portta logs traefik shows router matching. There isno per-query visibility and there should not be. Adding a protocol Protocol knowledge lives in one registry rather than scattered throughport-number conditionals, and each entry states what was verified: To add one: establish whether the client sends a TLS ClientHello, with SNI, before theserver sends anything. If the server speaks first, the answer is no unlessTraefik has explicit support for that protocol's STARTTLS; check whether the client offers an ALPN protocol, and add it to the TLSoptions if so. This is what broke PostgreSQL first; add the entry to the registry with its default port and strategy; add an integration test with two instances and distinct data. Oneinstance proves nothing: it would pass with the routing removed entirely. Never assume a TCP service supports SNI because another one does. Reproducing the findings The lab is two PostgreSQL and two Redis containers, none publishing a port,behind one Traefik with a TCP entrypoint per protocol, and a local CA. Theintegration test tests/e2e/tcp-routing.test.sh is that lab, automated, and itis what keeps these answers true. The questions it answers, in order: two instances on one port told apart byhostname, distinct data proving the route, TLS required, a connection with noSNI refused, a container restarted and still reachable, a container removed andits route gone, and Traefik restarted with both routes back.","headings":[{"id":"configure-tcp-routing","text":"Configure TCP routing","level":1,"line":0},{"id":"the-matrix","text":"The matrix","level":2,"line":20},{"id":"why-postgresql-works","text":"Why PostgreSQL works","level":2,"line":41},{"id":"the-part-the-documentation-does-not-mention-alpn","text":"The part the documentation does not mention: ALPN","level":3,"line":66},{"id":"why-mysql-does-not-work-and-what-happens-instead","text":"Why MySQL does not work, and what happens instead","level":2,"line":97},{"id":"redis-it-works-with-one-wart","text":"Redis: it works, with one wart","level":2,"line":151},{"id":"termination-not-passthrough","text":"Termination, not passthrough","level":2,"line":180},{"id":"naming-and-why-it-is-flat","text":"Naming, and why it is flat","level":2,"line":204},{"id":"networks-datastores-still-do-not-join-the-http-network","text":"Networks: datastores still do not join the HTTP network","level":2,"line":248},{"id":"exposure","text":"Exposure","level":2,"line":269},{"id":"what-a-hostname-that-matches-nothing-gets","text":"What a hostname that matches nothing gets","level":3,"line":298},{"id":"local-on-macos-with-docker-desktop-or-orbstack","text":"Local, on macOS with Docker Desktop or OrbStack","level":2,"line":326},{"id":"remote-on-debian-or-ubuntu","text":"Remote, on Debian or Ubuntu","level":2,"line":351},{"id":"when-sni-is-not-there-the-fallback","text":"When SNI is not there: the fallback","level":2,"line":373},{"id":"impact","text":"Impact","level":2,"line":397},{"id":"adding-a-protocol","text":"Adding a protocol","level":2,"line":426},{"id":"reproducing-the-findings","text":"Reproducing the findings","level":2,"line":452}],"kind":"markdown"},{"slug":"redis-access","title":"Connect to Redis","description":"Reach Redis without publishing its port.","source":"docs/product/guides/redis-access.md","audience":"user","section":"Guides","category":"Databases and TCP services","url":"/docs/redis-access","markdown":"# Connect to Redis\n\nIdentical in shape to [database access](database-access.md); the details differ\nslightly.\n\n## From the application\n\n`redis:6379` over the project's private network. Nothing to change.\n\n## From a GUI or redis-cli on the host\n\n```bash\nportta redis open --project base-empresarial\n# -> 127.0.0.1:33078\n```\n\n```bash\nredis-cli -h 127.0.0.1 -p 33078\n```\n\nOr with RedisInsight / TablePlus: host `127.0.0.1`, the printed port.\n\nThe panel's Access page offers the same addresses and, when `REDIS_PASSWORD`\nis in the container environment, a complete string. A routed Redis still\nneeds `redis-cli --tls --sni`, because redis-cli does not derive SNI from\n`-h`.\n\n## From the terminal, or from an agent\n\n```bash\nportta redis cli --project base-empresarial\nportta redis cli --project base-empresarial -- keys 'session:*'\nportta redis cli --project base-empresarial -- info memory\n```\n\nRuns inside the project's network. Nothing published, nothing left behind.\n\n## Several Redis instances at once\n\n```bash\nportta redis open --project base-empresarial # -> :33078\nportta redis open --project base-eleicoes # -> :33080\n```\n\nAll still on 6379 internally.\n\n## A project with more than one Redis\n\nCommon enough: one for cache, one for queues, often on 6379 and 6380 on the\nhost today. Name the service:\n\n```bash\nportta access open --project base-empresarial --service redis-cache\nportta access open --project base-empresarial --service redis-queue\n```\n\n`portta env services --project base-empresarial` lists what is there.\n\nBoth keep 6379 inside their containers. The 6379/6380 split only ever existed\nto avoid a host port conflict, and there is no host port any more.\n\n## A word of warning\n\n`FLUSHALL` on the wrong bridge is indistinguishable from `FLUSHALL` on the\nright one. Check which one you are on first:\n\n```bash\nportta access list\n```\n\n`portta redis cli --project <name>` is safer for exactly this reason: the\nproject is in the command.\n\n## Reaching it by hostname instead\n\nWith `PORTTA_TCP=true` and the project opted in, Redis has a stable\naddress on the shared port:\n\n```bash\nredis-cli -h 127.0.0.1 -p 6379 --tls --sni base-empresarial-redis.localhost\n```\n\n`--sni` is mandatory: `redis-cli` does not derive it from `-h`, and without it\nthe connection reaches nothing. Most client libraries do set it from the host\nthey are given. See [Configure TCP routing](tcp-routing.md).\n","text":"Connect to Redis Identical in shape to database access; the details differslightly. From the application redis:6379 over the project's private network. Nothing to change. From a GUI or redis-cli on the host Or with RedisInsight / TablePlus: host 127.0.0.1, the printed port. The panel's Access page offers the same addresses and, when REDIS_PASSWORDis in the container environment, a complete string. A routed Redis stillneeds redis-cli --tls --sni, because redis-cli does not derive SNI from-h. From the terminal, or from an agent Runs inside the project's network. Nothing published, nothing left behind. Several Redis instances at once All still on 6379 internally. A project with more than one Redis Common enough: one for cache, one for queues, often on 6379 and 6380 on thehost today. Name the service: portta env services --project base-empresarial lists what is there. Both keep 6379 inside their containers. The 6379/6380 split only ever existedto avoid a host port conflict, and there is no host port any more. A word of warning FLUSHALL on the wrong bridge is indistinguishable from FLUSHALL on theright one. Check which one you are on first: portta redis cli --project <name> is safer for exactly this reason: theproject is in the command. Reaching it by hostname instead With PORTTA_TCP=true and the project opted in, Redis has a stableaddress on the shared port: --sni is mandatory: redis-cli does not derive it from -h, and without itthe connection reaches nothing. Most client libraries do set it from the hostthey are given. See Configure TCP routing.","headings":[{"id":"connect-to-redis","text":"Connect to Redis","level":1,"line":0},{"id":"from-the-application","text":"From the application","level":2,"line":5},{"id":"from-a-gui-or-redis-cli-on-the-host","text":"From a GUI or redis-cli on the host","level":2,"line":9},{"id":"from-the-terminal-or-from-an-agent","text":"From the terminal, or from an agent","level":2,"line":27},{"id":"several-redis-instances-at-once","text":"Several Redis instances at once","level":2,"line":37},{"id":"a-project-with-more-than-one-redis","text":"A project with more than one Redis","level":2,"line":46},{"id":"a-word-of-warning","text":"A word of warning","level":2,"line":61},{"id":"reaching-it-by-hostname-instead","text":"Reaching it by hostname instead","level":2,"line":73}],"kind":"markdown"},{"slug":"tasks","title":"Manage tasks","description":"Create, organize and track local work with optional GitHub bindings.","source":"docs/product/guides/tasks.md","audience":"user","section":"Guides","category":"Tasks and integrations","url":"/docs/tasks","markdown":"# Manage tasks\n\nA Portta task is local work. It belongs to a Project and may point at a\nrepository, an environment and a service. A GitHub issue is an optional\nbinding, never the row itself. See [ADR 0032](../../development/adr/0032-portta-development-model.md).\n\nThe workspace is `/projects/:slug/tasks/:id`. `/tasks/:id` reaches the same page from an id alone, which is what `portta tasks show` prints. Creating a task from the panel\nis kick-create: **Nova tarefa** inserts a draft and opens that page. The title\nstarts as `New task` (shown localised). The first real edit promotes it. An\nuntouched draft stays off the board; a second click reopens it; intact drafts\nolder than 24 hours are removed.\n\n`portta tasks create`, MCP and GitHub sync write published tasks.\n\n## Board and editing\n\nThe board persists a sparse integer rank in `tasks.position`. Ranks normally\nhave a gap of 1024; moving a card writes a midpoint between its neighbours.\nOnly the destination column is compacted when no integer gap remains. The UI\nupdates optimistically and rolls back if `POST /api/tasks/:ref/move` fails.\n\nThe task page is read-first: the title and properties edit inline, while the\ndescription and comments share one GitHub-Flavoured Markdown editor and one\nsanitised renderer. Description edits autosave after 800 ms. Escape and a\nclick outside flush a pending save before returning to rendered Markdown; a\nfailed save keeps the local draft open so text is never discarded.\n\n## Status\n\nThe six statuses live in `TASK_STATUS_CATALOG` in `packages/core`. Each entry\nhas a tone, a category and whether it is terminal. A Project workflow selects\nand orders a subset of that catalog, may give each visible status a local\nlabel, and chooses a visible unstarted status as the default for create and\nkick-create. The task row always stores the catalog id, never the local label.\n\nHiding a status removes its board column and prevents API, CLI and panel writes\ninto it. Existing tasks in that status remain readable in the table and on\ntheir task pages, and may be moved to any visible status. This makes hiding a\nworkflow step reversible without rewriting historical work.\n\n## Attachments\n\nA task carries files: the screenshot of the bug, the log that proves it, the\nJSON the API actually returned. Attach one from the file picker, by dropping it\nonto the Attachments section, or by pasting a screenshot straight from the\nclipboard.\n\n| Limit | Value | Enforced by |\n|---|---|---|\n| Size per file | 10 MB | `ATTACHMENT_LIMITS` and a SQL `CHECK` |\n| Files per task | 25 | `ATTACHMENT_LIMITS` |\n| Filename | one path segment, 255 chars | `safeFilename` |\n\nThe bytes live in `task_attachments` in PostgreSQL rather than on disk. Every\nfilesystem path the panel touches is a channel shared with the host —\n`state/metrics` is written by the collector, `state/runner` is read by the\nrunner, `traefik-dynamic` is read by Traefik. An attachment is none of those:\nit belongs to a task, it is only ever read back through the API, and it must\ndisappear when the task does, which makes it a durable decision and puts it in\nthe database ([ADR 0013](../../development/adr/0013-what-the-panel-persists.md)). It also means\nan existing install gains attachments by running a migration rather than by\nre-running Compose with a new mount, and that a database backup is complete.\n\nThe content type is an allowlist, not a guess. A type the panel will render —\nPNG, JPEG, GIF, WebP, AVIF, PDF, plain text, Markdown, CSV, JSON — is kept;\nanything else, SVG included, is stored as `application/octet-stream` and only\never downloaded. SVG is excluded deliberately: it can carry script, and these\nbytes are served from the panel's own origin. Every download goes out with\n`X-Content-Type-Options: nosniff` and a `sandbox` CSP.\n\n`GET /api/tasks/:ref/attachments` lists the metadata; the bytes are behind the\n`downloadUrl` each entry carries, so a task with ten screenshots is not a\nten-megabyte JSON response. `POST` takes `multipart/form-data` with the file in\na `file` field, which is what a browser's file input and `curl -F` both speak.\n\n## Import and export\n\nExample projects under `PORTTA_PROJECTS_HOME/portta-demo-*/portta.example.json` are the first\nconsumers. The schema is versioned (`schemaVersion: 1`) and lives in\n`packages/core` as `ExampleDocument`.\n\nReferences are names: `repository`, `environment`, `service`, `parent`. Never\ndatabase ids. `key` is stored as `source_key` and is unique per project. A\nsecond apply updates the same rows.\n\n```bash\njust dev\njust dev --demo # the same data import, plus the example stacks\nportta examples apply # re-import manifests without cycling containers\n```\n\n`GET /api/projects/:slug/tasks/export` writes the same shape back. Tasks\nwithout a `source_key` export as `task-<id>` so a later import can reconcile.\n\n## GitHub binding\n\n`task_github_links.sync_state` is `synced`, `pending`, `conflict` or `error`.\n\n- No pending local edit → remote wins on the next sync.\n- Pending local edit and a still remote → keep local.\n- Both moved → `conflict`; resolve with `POST /tasks/:ref/github/sync` and\n `resolve: local|remote`, or take selected remote fields with\n `{ \"resolve\": \"fields\", \"fields\": [\"title\"] }`. A field-level resolution\n keeps every unselected local shared field and pushes the merged result back\n to GitHub.\n\nTitle, description, status, priority, labels and assignee travel across the binding.\nComments, parent, agent, type, service, due date and draft do not. A local\ncomment can be explicitly published as a copy on the bound issue; its GitHub\nid, URL and publication state are recorded without changing the local source\nof truth. A draft cannot\nbe published to GitHub until it has a real title.\n\nLinking an existing issue requires choosing an initial direction: `pull`\nimports the issue fields into Portta; `push` publishes the current Portta task.\nThere is no implicit winner at link time.\n\n## REST surface\n\nThe UI, CLI and MCP share the same task routes. `GET /api/tasks` offers global\nfiltering; project-scoped list/create routes remain available. A task supports\npartial `PATCH`, `DELETE`, `/move`, `/comments`, `/subtasks`, `/activity` and\nthe explicit `/github/*` operations. Mutations carry a source and generate the\nsame activity regardless of whether they came from web, CLI, MCP or API.\n\nThe task workspace presents one chronological timeline assembled from the\nexisting `activity_events`, local `task_notes`, and commits owned by the task's\ndevelopment sessions. Comments remain editable and deletable only by their\nauthor. Filters separate comments, task/system changes, and sessions with\ncommits; field changes use the structured `data.changes` payload instead of\nparsing summary prose. An intact kick-create draft has no creation event. Its\nfirst real edit, comment, attachment, status change, or assignment promotes it\nand records exactly one `task.created` before the resulting work appears.\n\n## Commits and a task\n\nHow Portta can tell which commits belong to a task, and how reliable each\nsignal is.\n\n| Strategy | Reliability | Notes |\n|---|---|---|\n| `task → dev_sessions → commits[]` | High | The session carries `task_id` and `repository_id`. `commit-watch` appends new HEADs to the active session. This is the path agents already use (`portta sessions start --task`). |\n| Explicit record at commit time | Highest | The same chain, written when the agent (or the host watcher) sees the commit, not reconstructed later. Prefer this. |\n| Task id / `PORTTA-123` / `task:123` in the message | Low | Easy to omit, forge or collide. Useful as a fallback, not as the source of truth. |\n| `#123` in the message | Low | Ambiguous once more than one GitHub repository is in play, and unused for local-only tasks. |\n| Branch `task-42-*` | Medium | Already used to infer `task_environments`. Good for \"this environment is for that task\", weaker for every commit on a long-lived branch. |\n| Linked GitHub issue + PR | Medium | Works when the task is bound and the PR is the unit of merge. Silent when GitHub is down or the work never opened a PR. |\n\n**Local git, GitHub API, or both?** Both, with different jobs. `portta repos\nscan` already collects the last twenty commits from the host without mounting\nthe tree into the panel. The GitHub API adds pull requests and remote-only\nhistory. Neither replaces the session record.\n\n**Agent commits.** An execution that starts as `task → session → repository`\nshould keep writing `dev_sessions.commits` (and `activity_events` of kind\n`repository.commit`) as HEAD moves. Heuristics on the subject line are a\nbackfill for sessions that were not opened, not the primary design.\n\n**A human-friendly task key (`BDH-42`).** A Project may reserve one globally\nunique prefix of 2–12 uppercase letters or digits. Published tasks receive the\nnext monotonic number under a Project-row lock; an intact draft receives none,\nand promotion mints exactly once. Enabling a prefix later assigns numbers to\nexisting published tasks in creation order. Changing the prefix changes their\ndisplay, not their stored number.\n\nThe key is a mnemonic reference, not identity. `#id` remains canonical in the\nAPI, MCP, labels, namespaces and branch conventions. API and CLI task routes\nalso resolve an unambiguous display key such as `BDH-42`. The import document's\nportable `source_key` remains unrelated and neither imports nor exports a\ndisplay number.\n\n**Several repositories.** One session is one repository. A task that spans\nrepos is several sessions, aggregated on the task page the way it already\naggregates session commits today.\n\nThe timeline shows the current commit list (sha, subject, actor, age), sourced\nfrom sessions. It does not scrape messages looking for `#123` and present that\nas certainty.\n","text":"Manage tasks A Portta task is local work. It belongs to a Project and may point at arepository, an environment and a service. A GitHub issue is an optionalbinding, never the row itself. See ADR 0032. The workspace is /projects/:slug/tasks/:id. /tasks/:id reaches the same page from an id alone, which is what portta tasks show prints. Creating a task from the panelis kick-create: Nova tarefa inserts a draft and opens that page. The titlestarts as New task (shown localised). The first real edit promotes it. Anuntouched draft stays off the board; a second click reopens it; intact draftsolder than 24 hours are removed. portta tasks create, MCP and GitHub sync write published tasks. Board and editing The board persists a sparse integer rank in tasks.position. Ranks normallyhave a gap of 1024; moving a card writes a midpoint between its neighbours.Only the destination column is compacted when no integer gap remains. The UIupdates optimistically and rolls back if POST /api/tasks/:ref/move fails. The task page is read-first: the title and properties edit inline, while thedescription and comments share one GitHub-Flavoured Markdown editor and onesanitised renderer. Description edits autosave after 800 ms. Escape and aclick outside flush a pending save before returning to rendered Markdown; afailed save keeps the local draft open so text is never discarded. Status The six statuses live in TASK_STATUS_CATALOG in packages/core. Each entryhas a tone, a category and whether it is terminal. A Project workflow selectsand orders a subset of that catalog, may give each visible status a locallabel, and chooses a visible unstarted status as the default for create andkick-create. The task row always stores the catalog id, never the local label. Hiding a status removes its board column and prevents API, CLI and panel writesinto it. Existing tasks in that status remain readable in the table and ontheir task pages, and may be moved to any visible status. This makes hiding aworkflow step reversible without rewriting historical work. Attachments A task carries files: the screenshot of the bug, the log that proves it, theJSON the API actually returned. Attach one from the file picker, by dropping itonto the Attachments section, or by pasting a screenshot straight from theclipboard. Limit Value Enforced by Size per file 10 MB ATTACHMENT_LIMITS and a SQL CHECK Files per task 25 ATTACHMENT_LIMITS Filename one path segment, 255 chars safeFilename The bytes live in task_attachments in PostgreSQL rather than on disk. Everyfilesystem path the panel touches is a channel shared with the host —state/metrics is written by the collector, state/runner is read by therunner, traefik-dynamic is read by Traefik. An attachment is none of those:it belongs to a task, it is only ever read back through the API, and it mustdisappear when the task does, which makes it a durable decision and puts it inthe database (ADR 0013). It also meansan existing install gains attachments by running a migration rather than byre-running Compose with a new mount, and that a database backup is complete. The content type is an allowlist, not a guess. A type the panel will render —PNG, JPEG, GIF, WebP, AVIF, PDF, plain text, Markdown, CSV, JSON — is kept;anything else, SVG included, is stored as application/octet-stream and onlyever downloaded. SVG is excluded deliberately: it can carry script, and thesebytes are served from the panel's own origin. Every download goes out withX-Content-Type-Options: nosniff and a sandbox CSP. GET /api/tasks/:ref/attachments lists the metadata; the bytes are behind thedownloadUrl each entry carries, so a task with ten screenshots is not aten-megabyte JSON response. POST takes multipart/form-data with the file ina file field, which is what a browser's file input and curl -F both speak. Import and export Example projects under PORTTA_PROJECTS_HOME/portta-demo-*/portta.example.json are the firstconsumers. The schema is versioned (schemaVersion: 1) and lives inpackages/core as ExampleDocument. References are names: repository, environment, service, parent. Neverdatabase ids. key is stored as source_key and is unique per project. Asecond apply updates the same rows. GET /api/projects/:slug/tasks/export writes the same shape back. Taskswithout a source_key export as task-<id> so a later import can reconcile. GitHub binding task_github_links.sync_state is synced, pending, conflict or error. No pending local edit → remote wins on the next sync. Pending local edit and a still remote → keep local. Both moved → conflict; resolve with POST /tasks/:ref/github/sync andresolve: local|remote, or take selected remote fields with{ \"resolve\": \"fields\", \"fields\": [\"title\"] }. A field-level resolutionkeeps every unselected local shared field and pushes the merged result backto GitHub. Title, description, status, priority, labels and assignee travel across the binding.Comments, parent, agent, type, service, due date and draft do not. A localcomment can be explicitly published as a copy on the bound issue; its GitHubid, URL and publication state are recorded without changing the local sourceof truth. A draft cannotbe published to GitHub until it has a real title. Linking an existing issue requires choosing an initial direction: pullimports the issue fields into Portta; push publishes the current Portta task.There is no implicit winner at link time. REST surface The UI, CLI and MCP share the same task routes. GET /api/tasks offers globalfiltering; project-scoped list/create routes remain available. A task supportspartial PATCH, DELETE, /move, /comments, /subtasks, /activity andthe explicit /github/* operations. Mutations carry a source and generate thesame activity regardless of whether they came from web, CLI, MCP or API. The task workspace presents one chronological timeline assembled from theexisting activity_events, local task_notes, and commits owned by the task'sdevelopment sessions. Comments remain editable and deletable only by theirauthor. Filters separate comments, task/system changes, and sessions withcommits; field changes use the structured data.changes payload instead ofparsing summary prose. An intact kick-create draft has no creation event. Itsfirst real edit, comment, attachment, status change, or assignment promotes itand records exactly one task.created before the resulting work appears. Commits and a task How Portta can tell which commits belong to a task, and how reliable eachsignal is. Strategy Reliability Notes task → dev_sessions → commits[] High The session carries task_id and repository_id. commit-watch appends new HEADs to the active session. This is the path agents already use (portta sessions start --task). Explicit record at commit time Highest The same chain, written when the agent (or the host watcher) sees the commit, not reconstructed later. Prefer this. Task id / PORTTA-123 / task:123 in the message Low Easy to omit, forge or collide. Useful as a fallback, not as the source of truth. #123 in the message Low Ambiguous once more than one GitHub repository is in play, and unused for local-only tasks. Branch task-42-* Medium Already used to infer task_environments. Good for \"this environment is for that task\", weaker for every commit on a long-lived branch. Linked GitHub issue + PR Medium Works when the task is bound and the PR is the unit of merge. Silent when GitHub is down or the work never opened a PR. Local git, GitHub API, or both? Both, with different jobs. portta repos scan already collects the last twenty commits from the host without mountingthe tree into the panel. The GitHub API adds pull requests and remote-onlyhistory. Neither replaces the session record. Agent commits. An execution that starts as task → session → repositoryshould keep writing dev_sessions.commits (and activity_events of kindrepository.commit) as HEAD moves. Heuristics on the subject line are abackfill for sessions that were not opened, not the primary design. A human-friendly task key (BDH-42). A Project may reserve one globallyunique prefix of 2–12 uppercase letters or digits. Published tasks receive thenext monotonic number under a Project-row lock; an intact draft receives none,and promotion mints exactly once. Enabling a prefix later assigns numbers toexisting published tasks in creation order. Changing the prefix changes theirdisplay, not their stored number. The key is a mnemonic reference, not identity. #id remains canonical in theAPI, MCP, labels, namespaces and branch conventions. API and CLI task routesalso resolve an unambiguous display key such as BDH-42. The import document'sportable source_key remains unrelated and neither imports nor exports adisplay number. Several repositories. One session is one repository. A task that spansrepos is several sessions, aggregated on the task page the way it alreadyaggregates session commits today. The timeline shows the current commit list (sha, subject, actor, age), sourcedfrom sessions. It does not scrape messages looking for #123 and present thatas certainty.","headings":[{"id":"manage-tasks","text":"Manage tasks","level":1,"line":0},{"id":"board-and-editing","text":"Board and editing","level":2,"line":14},{"id":"status","text":"Status","level":2,"line":27},{"id":"attachments","text":"Attachments","level":2,"line":40},{"id":"import-and-export","text":"Import and export","level":2,"line":75},{"id":"github-binding","text":"GitHub binding","level":2,"line":94},{"id":"rest-surface","text":"REST surface","level":2,"line":117},{"id":"commits-and-a-task","text":"Commits and a task","level":2,"line":134}],"kind":"markdown"},{"slug":"github","title":"Connect GitHub","description":"Configure a GitHub App and connect repositories.","source":"docs/product/guides/github.md","audience":"user","section":"Guides","category":"Tasks and integrations","url":"/docs/github","markdown":"# Connect GitHub\n\nConfigure a GitHub App to synchronize repositories and issues with local Portta tasks. The integration is disabled until you enable it.\n\n## Default posture\n\n- No App configured: the panel behaves as it does today.\n- App configured: outbound calls to `api.github.com` on the network the\n panel already has. No new Docker network.\n- Webhooks off by default. A loopback panel cannot receive them, and a\n routed one refuses them until the signed path is exempted (step 7).\n Correctness comes from reconciliation. Webhooks are an optimisation\n for a panel the operator has already published.\n- The panel stays refused on the public profile.\n- Read-only mode refuses GitHub writes too.\n\n\n\n## Setting it up\n\nTen minutes, at the end of which **Settings → GitHub** shows a connected App, the\ninstallations it has, and the repositories they granted.\n\nNone of it needs a public address. The panel calls GitHub; GitHub does not call\nthe panel, unless you turn webhooks on in step 7, which is optional and which a\nloopback panel should skip.\n\n### Before you start\n\n- Portta running, and the panel open.\n- A GitHub account you can create an App on.\n- A shell on the host. The private key is a **file you put there**, not a value\n the panel will accept — the panel can write its own `.env`, and must not be\n able to write the key that authenticates it.\n\n### 1. Create the App\n\nGitHub → *Settings* → *Developer settings* → *GitHub Apps* → *New GitHub App*.\n\n| Field on the form | What to put | Why |\n|---|---|---|\n| **GitHub App name** | anything unique, e.g. `portta-<your account>` | GitHub requires it to be unique across all of GitHub |\n| **Homepage URL** | anything at all, e.g. your repository | The form demands one; the panel never serves it |\n| **Callback URL** | leave it empty | There is no OAuth flow. The panel never receives a redirect |\n| **Setup URL** | leave it empty | The panel *discovers* its installations through `GET /app/installations`. It is never told about one |\n| **Webhook → Active** | **unticked** | A loopback panel cannot receive a delivery. Step 7 turns this on if yours is already published |\n| **Where can this App be installed** | *Only on this account* | The right answer for a development host |\n\nCreate the App, and keep the page open: the App id and the private key both come\nfrom it.\n\n### 2. Ask for these permissions, and no others\n\nUnder *Repository permissions*. Three of them are what the panel calls today:\n\n| Permission | Access | The call it pays for |\n|---|---|---|\n| **Metadata** | Read | `GET /installation/repositories`. Mandatory — it is what lists repositories at all |\n| **Issues** | Read and write | `GET`, `POST` and `PATCH /repos/{owner}/{repo}/issues`, and `…/issues/{n}/sub_issues`. The board writes back to GitHub |\n| **Pull requests** | Read | GitHub's issues endpoint returns pull requests too, and a project page shows the open ones |\n\nThree more belong to the phases after this one. Granting them now costs nothing\nand saves a second trip through this form; leaving them out changes nothing you\ncan see today:\n\n| Permission | Access | What it is for |\n|---|---|---|\n| **Contents** | Read | Repository shape beyond the default branch, which Metadata already carries |\n| **Commit statuses** | Read | Whether checks passed |\n| **Checks** | Read | The same, through the Checks API |\n\n**Never `Contents: write`.** The panel does not commit, push, merge or rebase,\nand an App that cannot write code cannot be talked into it.\n\n### 3. Install it\n\n*Install App* in the App's sidebar, then install it on your account and choose\nbetween *All repositories* and *Only select repositories*.\n\nThat choice is the authorisation boundary. The panel refuses any operation on a\nrepository the installation did not grant, before it makes the request — so\npicking a few repositories now is not a decision you have to get right: widening\nit later is *Install App → Configure*, and the next **Sync** picks the change up.\n\n### 4. Put the private key on the host\n\nOn the App's settings page, *Private keys* → *Generate a private key*. Your\nbrowser downloads a `.pem`. In your Portta directory, on the host:\n\n```bash\nmkdir -p state/github\nmv ~/Downloads/your-app.*.private-key.pem state/github/\nchmod 600 state/github/*.pem\n```\n\n**The directory matters, the filename does not.** Compose mounts\n`./state/github` into the panel read-only, and that mount is the only route the\nkey has into the container (`docker/compose/features/web.yaml`). So the .pem has\nto live there, under whatever name you like: keep the one GitHub gave the\ndownload, or rename it to `app.pem`, which is what the panel assumes when you\nset nothing. Whichever you choose, step 5 is where you say so.\n\n`chmod 600` is not ceremony. The panel checks the mode as it starts and writes\n`… is readable by more than its owner: chmod 600 it`; `portta doctor` fails on\nit. The key is read on **every** use rather than cached, so rotating it later is\na `mv` and needs no restart.\n\n### 5. Fill in Settings → GitHub\n\nOpen the panel, go to **Settings → GitHub**, and fill the five fields in the\norder they appear:\n\n| Field on the screen | Key | What to put | Refused if |\n|---|---|---|---|\n| **GitHub App** (toggle) | `GITHUB_APP_ENABLED` | on | — |\n| **App id** | `GITHUB_APP_ID` | the number at the top of the App's settings page, e.g. `123456` | it is not digits alone |\n| **Private key file** | `GITHUB_APP_PRIVATE_KEY_FILE` | `/app/state/github/` and the filename you used in step 4 | it is not under `/app/state/github/` |\n| **Webhook secret** | `GITHUB_APP_WEBHOOK_SECRET` | leave it empty for now | — |\n| **API base URL** | `GITHUB_API_URL` | `https://api.github.com`, or `https://ghe.example.com/api/v3` on Enterprise Server | it is not a URL |\n| **Reconciliation interval** | `GITHUB_SYNC_INTERVAL_MINUTES` | `15`, or `0` on a panel that receives webhooks | it is not a whole number |\n\nThree of those are worth a sentence each.\n\nThe **App id** is the App id — not the App name, and not the client id. The\nfield takes digits and nothing else.\n\nThe **private key file** is the path *inside the container*, which is why it\nbegins `/app/` and not with your home directory. `state/github/` on the host is\n`/app/state/github/` there, so a key you dropped in as\n`portta.2026-09-02.private-key.pem` is\n`/app/state/github/portta.2026-09-02.private-key.pem` here. The field is\nrefused if it points anywhere else, because nothing else is mounted and the\npanel could not open it. Leave it empty and the panel reads\n`/app/state/github/app.pem`.\n\nThis is the value both diagnostics use: the panel opens the file you name, and\n`portta doctor` checks that same file on the host.\n\nThe **webhook secret** field shows *not set* or *set*, never a value. No secret\nis ever returned by the API, and the `.env` it is written to is mode 600.\n\nThen press **Save**. **Saving writes `.env`. It does not apply it** — which is\nwhat the bar at the top of the page is telling you.\n\n### 6. Apply it, and see that it worked\n\n```bash\n./bin/portta up local\nportta doctor\n```\n\n`up local` recreates the container, and recreating is what makes a changed\n`.env` take effect. **`portta web restart` will not do this**: it restarts the\nprocess with the environment it already had, and the App stays invisible. On a\nhost with `PORTTA_APPLY=true`, the panel's own *Apply and restart* button\nperforms the same recreate for you.\n\n`doctor` has three checks here, and they are silent when the App is off:\n\n| Check | Passes when |\n|---|---|\n| `github.app` | the App is enabled and `GITHUB_APP_ID` is set |\n| `github.key` | the `.pem` exists, is readable, and is mode `600` or `400` |\n| `github.api` | `GITHUB_API_URL` is `https://` |\n\nNow reload **Settings → GitHub**. The card that said *No GitHub App is\nconfigured* shows a **connected** badge, `App <id> · <api url>`, and four\nthings:\n\n- **Installations** — one badge each. A suspended installation says so, and the\n sync skips it.\n- **Repositories** — how many those installations granted. Zero is not a\n failure; it is an installation that granted none. *Install App → Configure*\n is where that is fixed.\n- **Rate limit** — what is left of the budget, and when it resets.\n- **Last sync** — per scope, with the last error in red when there was one.\n\nPress **Sync**. It is idempotent: two runs leave the same rows, move\n`synced_at`, and prune whatever an installation no longer grants.\n\n### 7. Webhooks, if the panel is already published (optional)\n\nSkip this unless the panel has a URL GitHub can reach. Correctness does not\ndepend on a delivery — reconciliation is the baseline, and a webhook only makes\nthe panel notice sooner.\n\n**A delivery has no session, and every other panel path requires one.** GitHub\nsends no cookie and no Basic credential, so ForwardAuth refuses a delivery\nbefore the panel ever sees it — a `401` with an empty body, and nothing in the\npanel's log. One overlay exempts exactly one path from that middleware:\n\n```text\ndocker/compose/features/panel-webhook.yaml\n```\n\nIt is applied when `GITHUB_APP_ENABLED=true` **and** the panel is routed with\n`PORTTA_WEB_EXPOSE=domain`. Both halves matter: `domain` is the only mode that\ngives the panel a hostname over HTTPS, and GitHub will not deliver to the plain\nHTTP the `panel` entrypoint serves. `portta doctor` warns when the App is on and\nthe panel is in any other mode, because the symptom otherwise is deliveries\nGitHub retries and this host refuses, invisibly.\n\n**Why that exemption is not a hole.** The path is not unauthenticated; it\nauthenticates differently, and for a machine-to-machine callback more strongly\nthan a cookie would. GitHub signs the raw body with HMAC-SHA256 under a secret\nonly it and this host know, and nothing is parsed before that check passes. A\nsession cookie would be the wrong instrument here — GitHub has no session, and\nany scheme that let it in by origin or by address would trust something\nforgeable.\n\nIt is **not** a general \"these URLs are public\" list, and Portta does not offer\none. Every other panel path authenticates by session and by nothing else, so\nexempting any of them would open an unauthenticated door into an API that can\nstart, stop and remove containers. The router names one exact path with\n`Path(...)`, never a prefix.\n\nGenerate a secret and keep it where you can paste it twice:\n\n```bash\nopenssl rand -hex 32\n```\n\nOn the App's settings page, under *Webhook*:\n\n| Field | Value |\n|---|---|\n| **Active** | ticked |\n| **Payload URL** | `https://<PORTTA_PANEL_ADVERTISED_HOST>/api/integrations/github/webhook` |\n| **Content type** | `application/json` |\n| **Secret** | the string you just generated |\n\nThen *Permissions & events* → *Subscribe to events*, and tick exactly these\neight, which are the ones the panel acts on:\n\n*Issues* · *Label* · *Milestone* · *Sub-issues* ·\n*Pull request* · *Repository* · *Installation* · *Installation repositories*\n\n*Issue comment* is deliberately **not** among them. Nothing projects a comment,\nso a delivery would buy a whole repository reconciliation to refresh one\ntimestamp — on the event that fires most often in an active repository.\n\nAnything else is acknowledged and dropped. An unhandled event is not an error.\n\nFinally, paste the same secret into **Settings → GitHub → Webhook secret**, save,\nand run `./bin/portta up local` again.\n\nThe signature is verified over the raw body, in constant time, *before* the body\nis parsed as anything meaningful. An invalid one is a `401` that logs the\ndelivery id and nothing else. A delivery is a signal to re-read, never data to\ntrust, so nothing GitHub sends widens what the installation granted. Read-only\nmode refuses the route outright.\n\n**Where the secret lives.**\n[ADR 0018](../../development/adr/0018-github-access-lives-in-the-panel.md) prescribed a file with\nits path in `.env`, the shape the private key has. Today it is a write-only\n`.env` value that the Settings page can set. The credential that *authenticates*\nthe App is the one that is a file, and that has not moved.\n\n### When it does not work\n\n| What you see | Why | Fix |\n|---|---|---|\n| *No GitHub App is configured*, after saving | `.env` was written; the container still has the old environment | `./bin/portta up local` |\n| Save refuses the App id | it is validated as digits only | use the numeric id, not the App name and not the client id |\n| Save refuses the key path | it must be under the one mounted directory | `/app/state/github/<your-file>.pem` |\n| doctor: `enabled with no GITHUB_APP_ID` | the toggle is on and the id is empty | copy the id from the App's settings page |\n| doctor: `no private key at …` | no `.pem` at the name the field gives, under `state/github/` | correct the filename on one side or the other |\n| doctor: `… is outside /app/state/github/` | a path from before the field took effect | move the `.pem` into `state/github/` and re-point the field |\n| doctor: `readable by more than its owner` | the key's mode | `chmod 600` the file doctor named |\n| doctor: `GITHUB_API_URL is not https` | an API root without TLS | use an `https://` root |\n| **unreachable**, *GitHub refused the App credentials* | the id and the key belong to different Apps, or the App was deleted | regenerate the key and re-copy the id |\n| **Repositories: 0** after a Sync | the installation granted none | *Install App → Configure* on GitHub |\n| An installation marked *(suspended)* | it is suspended on GitHub | unsuspend it; the sync skips suspended ones |\n| `503` on an issues page | the panel's PostgreSQL is unavailable, not GitHub | [Persistence](../concepts/persistence.md) |\n| The webhook answers `401` | the secret differs between GitHub and the panel | set the same string on both sides |\n\nThe rest of the panel is unaffected by any of these: a GitHub failure never\nstops a Docker-backed page from answering. See [Troubleshooting](troubleshooting.md)\nand [Security](../concepts/security.md).\n\n\n\n## When GitHub is down\n\nThe GitHub endpoints answer `503` with a hint, exactly as the database's do.\nThe projected repository list still answers, because it is read from\nPostgreSQL. Every Docker-backed page is unaffected: the panel never blocks a\nsnapshot on a network call it does not control.\n\nRate-limit exhaustion is a typed error rather than a 500, and the remaining\nbudget is on **Settings → GitHub** and in `GET /api/status` so it is visible\nbefore it runs out.\n\n\n\n## The API\n\n| Endpoint | What it does |\n|---|---|\n| `GET /api/integrations/github` | Configuration, reachability, installations, repository count, rate-limit budget, last sync. Never a secret |\n| `GET /api/integrations/github/repositories` | The projection, served from the database so it answers while GitHub is down |\n| `POST /api/integrations/github/sync` | Idempotent re-sync. Refused in read-only mode |\n\n`GET /api/status` carries the same `github` block, so one request tells an\nagent whether the integration is usable.\n\n\n\nSee [GitHub synchronization](../concepts/github-sync.md) for source ownership and synchronization behavior.\n","text":"Connect GitHub Configure a GitHub App to synchronize repositories and issues with local Portta tasks. The integration is disabled until you enable it. Default posture No App configured: the panel behaves as it does today. App configured: outbound calls to api.github.com on the network thepanel already has. No new Docker network. Webhooks off by default. A loopback panel cannot receive them, and arouted one refuses them until the signed path is exempted (step 7).Correctness comes from reconciliation. Webhooks are an optimisationfor a panel the operator has already published. The panel stays refused on the public profile. Read-only mode refuses GitHub writes too. Setting it up Ten minutes, at the end of which Settings → GitHub shows a connected App, theinstallations it has, and the repositories they granted. None of it needs a public address. The panel calls GitHub; GitHub does not callthe panel, unless you turn webhooks on in step 7, which is optional and which aloopback panel should skip. Before you start Portta running, and the panel open. A GitHub account you can create an App on. A shell on the host. The private key is a file you put there, not a valuethe panel will accept — the panel can write its own .env, and must not beable to write the key that authenticates it. 1. Create the App GitHub → Settings → Developer settings → GitHub Apps → New GitHub App. Field on the form What to put Why GitHub App name anything unique, e.g. portta-<your account> GitHub requires it to be unique across all of GitHub Homepage URL anything at all, e.g. your repository The form demands one; the panel never serves it Callback URL leave it empty There is no OAuth flow. The panel never receives a redirect Setup URL leave it empty The panel discovers its installations through GET /app/installations. It is never told about one Webhook → Active unticked A loopback panel cannot receive a delivery. Step 7 turns this on if yours is already published Where can this App be installed Only on this account The right answer for a development host Create the App, and keep the page open: the App id and the private key both comefrom it. 2. Ask for these permissions, and no others Under Repository permissions. Three of them are what the panel calls today: Permission Access The call it pays for Metadata Read GET /installation/repositories. Mandatory — it is what lists repositories at all Issues Read and write GET, POST and PATCH /repos/{owner}/{repo}/issues, and …/issues/{n}/sub_issues. The board writes back to GitHub Pull requests Read GitHub's issues endpoint returns pull requests too, and a project page shows the open ones Three more belong to the phases after this one. Granting them now costs nothingand saves a second trip through this form; leaving them out changes nothing youcan see today: Permission Access What it is for Contents Read Repository shape beyond the default branch, which Metadata already carries Commit statuses Read Whether checks passed Checks Read The same, through the Checks API Never Contents: write. The panel does not commit, push, merge or rebase,and an App that cannot write code cannot be talked into it. 3. Install it Install App in the App's sidebar, then install it on your account and choosebetween All repositories and Only select repositories. That choice is the authorisation boundary. The panel refuses any operation on arepository the installation did not grant, before it makes the request — sopicking a few repositories now is not a decision you have to get right: wideningit later is Install App → Configure, and the next Sync picks the change up. 4. Put the private key on the host On the App's settings page, Private keys → Generate a private key. Yourbrowser downloads a .pem. In your Portta directory, on the host: The directory matters, the filename does not. Compose mounts./state/github into the panel read-only, and that mount is the only route thekey has into the container (docker/compose/features/web.yaml). So the .pem hasto live there, under whatever name you like: keep the one GitHub gave thedownload, or rename it to app.pem, which is what the panel assumes when youset nothing. Whichever you choose, step 5 is where you say so. chmod 600 is not ceremony. The panel checks the mode as it starts and writes… is readable by more than its owner: chmod 600 it; portta doctor fails onit. The key is read on every use rather than cached, so rotating it later isa mv and needs no restart. 5. Fill in Settings → GitHub Open the panel, go to Settings → GitHub, and fill the five fields in theorder they appear: Field on the screen Key What to put Refused if GitHub App (toggle) GITHUB_APP_ENABLED on — App id GITHUB_APP_ID the number at the top of the App's settings page, e.g. 123456 it is not digits alone Private key file GITHUB_APP_PRIVATE_KEY_FILE /app/state/github/ and the filename you used in step 4 it is not under /app/state/github/ Webhook secret GITHUB_APP_WEBHOOK_SECRET leave it empty for now — API base URL GITHUB_API_URL https://api.github.com, or https://ghe.example.com/api/v3 on Enterprise Server it is not a URL Reconciliation interval GITHUB_SYNC_INTERVAL_MINUTES 15, or 0 on a panel that receives webhooks it is not a whole number Three of those are worth a sentence each. The App id is the App id — not the App name, and not the client id. Thefield takes digits and nothing else. The private key file is the path inside the container, which is why itbegins /app/ and not with your home directory. state/github/ on the host is/app/state/github/ there, so a key you dropped in asportta.2026-09-02.private-key.pem is/app/state/github/portta.2026-09-02.private-key.pem here. The field isrefused if it points anywhere else, because nothing else is mounted and thepanel could not open it. Leave it empty and the panel reads/app/state/github/app.pem. This is the value both diagnostics use: the panel opens the file you name, andportta doctor checks that same file on the host. The webhook secret field shows not set or set, never a value. No secretis ever returned by the API, and the .env it is written to is mode 600. Then press Save. Saving writes .env. It does not apply it — which iswhat the bar at the top of the page is telling you. 6. Apply it, and see that it worked up local recreates the container, and recreating is what makes a changed.env take effect. portta web restart will not do this: it restarts theprocess with the environment it already had, and the App stays invisible. On ahost with PORTTA_APPLY=true, the panel's own Apply and restart buttonperforms the same recreate for you. doctor has three checks here, and they are silent when the App is off: Check Passes when github.app the App is enabled and GITHUB_APP_ID is set github.key the .pem exists, is readable, and is mode 600 or 400 github.api GITHUB_API_URL is https:// Now reload Settings → GitHub. The card that said No GitHub App isconfigured shows a connected badge, App <id> · <api url>, and fourthings: Installations — one badge each. A suspended installation says so, and thesync skips it. Repositories — how many those installations granted. Zero is not afailure; it is an installation that granted none. Install App → Configureis where that is fixed. Rate limit — what is left of the budget, and when it resets. Last sync — per scope, with the last error in red when there was one. Press Sync. It is idempotent: two runs leave the same rows, movesynced_at, and prune whatever an installation no longer grants. 7. Webhooks, if the panel is already published (optional) Skip this unless the panel has a URL GitHub can reach. Correctness does notdepend on a delivery — reconciliation is the baseline, and a webhook only makesthe panel notice sooner. A delivery has no session, and every other panel path requires one. GitHubsends no cookie and no Basic credential, so ForwardAuth refuses a deliverybefore the panel ever sees it — a 401 with an empty body, and nothing in thepanel's log. One overlay exempts exactly one path from that middleware: It is applied when GITHUB_APP_ENABLED=true and the panel is routed withPORTTA_WEB_EXPOSE=domain. Both halves matter: domain is the only mode thatgives the panel a hostname over HTTPS, and GitHub will not deliver to the plainHTTP the panel entrypoint serves. portta doctor warns when the App is on andthe panel is in any other mode, because the symptom otherwise is deliveriesGitHub retries and this host refuses, invisibly. Why that exemption is not a hole. The path is not unauthenticated; itauthenticates differently, and for a machine-to-machine callback more stronglythan a cookie would. GitHub signs the raw body with HMAC-SHA256 under a secretonly it and this host know, and nothing is parsed before that check passes. Asession cookie would be the wrong instrument here — GitHub has no session, andany scheme that let it in by origin or by address would trust somethingforgeable. It is not a general \"these URLs are public\" list, and Portta does not offerone. Every other panel path authenticates by session and by nothing else, soexempting any of them would open an unauthenticated door into an API that canstart, stop and remove containers. The router names one exact path withPath(...), never a prefix. Generate a secret and keep it where you can paste it twice: On the App's settings page, under Webhook: Field Value Active ticked Payload URL https://<PORTTA_PANEL_ADVERTISED_HOST>/api/integrations/github/webhook Content type application/json Secret the string you just generated Then Permissions & events → Subscribe to events, and tick exactly theseeight, which are the ones the panel acts on: Issues · Label · Milestone · Sub-issues ·Pull request · Repository · Installation · Installation repositories Issue comment is deliberately not among them. Nothing projects a comment,so a delivery would buy a whole repository reconciliation to refresh onetimestamp — on the event that fires most often in an active repository. Anything else is acknowledged and dropped. An unhandled event is not an error. Finally, paste the same secret into Settings → GitHub → Webhook secret, save,and run ./bin/portta up local again. The signature is verified over the raw body, in constant time, before the bodyis parsed as anything meaningful. An invalid one is a 401 that logs thedelivery id and nothing else. A delivery is a signal to re-read, never data totrust, so nothing GitHub sends widens what the installation granted. Read-onlymode refuses the route outright. Where the secret lives.ADR 0018 prescribed a file withits path in .env, the shape the private key has. Today it is a write-only.env value that the Settings page can set. The credential that authenticatesthe App is the one that is a file, and that has not moved. When it does not work What you see Why Fix No GitHub App is configured, after saving .env was written; the container still has the old environment ./bin/portta up local Save refuses the App id it is validated as digits only use the numeric id, not the App name and not the client id Save refuses the key path it must be under the one mounted directory /app/state/github/<your-file>.pem doctor: enabled with no GITHUB_APP_ID the toggle is on and the id is empty copy the id from the App's settings page doctor: no private key at … no .pem at the name the field gives, under state/github/ correct the filename on one side or the other doctor: … is outside /app/state/github/ a path from before the field took effect move the .pem into state/github/ and re-point the field doctor: readable by more than its owner the key's mode chmod 600 the file doctor named doctor: GITHUB_API_URL is not https an API root without TLS use an https:// root unreachable, GitHub refused the App credentials the id and the key belong to different Apps, or the App was deleted regenerate the key and re-copy the id Repositories: 0 after a Sync the installation granted none Install App → Configure on GitHub An installation marked (suspended) it is suspended on GitHub unsuspend it; the sync skips suspended ones 503 on an issues page the panel's PostgreSQL is unavailable, not GitHub Persistence The webhook answers 401 the secret differs between GitHub and the panel set the same string on both sides The rest of the panel is unaffected by any of these: a GitHub failure neverstops a Docker-backed page from answering. See Troubleshootingand Security. When GitHub is down The GitHub endpoints answer 503 with a hint, exactly as the database's do.The projected repository list still answers, because it is read fromPostgreSQL. Every Docker-backed page is unaffected: the panel never blocks asnapshot on a network call it does not control. Rate-limit exhaustion is a typed error rather than a 500, and the remainingbudget is on Settings → GitHub and in GET /api/status so it is visiblebefore it runs out. The API Endpoint What it does GET /api/integrations/github Configuration, reachability, installations, repository count, rate-limit budget, last sync. Never a secret GET /api/integrations/github/repositories The projection, served from the database so it answers while GitHub is down POST /api/integrations/github/sync Idempotent re-sync. Refused in read-only mode GET /api/status carries the same github block, so one request tells anagent whether the integration is usable. See GitHub synchronization for source ownership and synchronization behavior.","headings":[{"id":"connect-github","text":"Connect GitHub","level":1,"line":0},{"id":"default-posture","text":"Default posture","level":2,"line":4},{"id":"setting-it-up","text":"Setting it up","level":2,"line":18},{"id":"before-you-start","text":"Before you start","level":3,"line":27},{"id":"1-create-the-app","text":"1. Create the App","level":3,"line":35},{"id":"2-ask-for-these-permissions-and-no-others","text":"2. Ask for these permissions, and no others","level":3,"line":51},{"id":"3-install-it","text":"3. Install it","level":3,"line":74},{"id":"4-put-the-private-key-on-the-host","text":"4. Put the private key on the host","level":3,"line":84},{"id":"5-fill-in-settings--github","text":"5. Fill in Settings → GitHub","level":3,"line":107},{"id":"6-apply-it-and-see-that-it-worked","text":"6. Apply it, and see that it worked","level":3,"line":144},{"id":"7-webhooks-if-the-panel-is-already-published-optional","text":"7. Webhooks, if the panel is already published (optional)","level":3,"line":180},{"id":"when-it-does-not-work","text":"When it does not work","level":3,"line":258},{"id":"when-github-is-down","text":"When GitHub is down","level":2,"line":282},{"id":"the-api","text":"The API","level":2,"line":295}],"kind":"markdown"},{"slug":"use-api","title":"Use the Portta API","description":"Authenticate API clients and find the endpoint contract.","source":"docs/product/guides/use-api.md","audience":"user","section":"Guides","category":"Tasks and integrations","url":"/docs/use-api","markdown":"# Use the Portta API\n\n### API contract\n\nThe panel publishes an OpenAPI 3.1 contract at\n`http://127.0.0.1:8081/api/openapi.json`. It is generated from the same route\nregistrations and Zod schemas the server and UI use: parameters, request\nbodies, response shapes, status codes, read-only refusals and the SSE payload\nare all part of the document. It declares the host-scoped Portta session and\nBearer tokens used by CLI and agent clients.\n\n`http://127.0.0.1:8081/docs/api` renders that document: operations grouped by\ntag, resolved schemas for parameters, request bodies and responses, the\ndeclared security schemes, and a console.\n\nThe console executes a `GET` on a click. A `POST`, `PUT`, `PATCH` or `DELETE`\nsays what it is about to send and waits for a second, explicit confirmation,\nbecause it is a real request against this panel. Read-only mode and the\nsame-origin write guard come back as the API's own error payload rather than as\na generic failure, so a refusal reads as a refusal.\n\nIt is enabled by default only while the panel stays on loopback. A routed panel\nreturns 404 unless `PORTTA_RUNTIME_API_DOCS=true` explicitly opts in. The JSON\ncontract stays available because a caller that reached the API can already\ninspect it.\n\n`packages/contracts/openapi.json` is checked in so an API change is visible in\nreview. `npm run openapi:check --workspace=portta-contracts` regenerates it in\nmemory and fails on byte-level drift. Adding or changing a route therefore\nrequires updating its attached description and running\n`npm run openapi --workspace=portta-contracts`.\n\n\nSee [Authentication](authentication.md#tokens-for-the-cli-and-agents) for personal API tokens. The endpoint reference is served by this panel at `/docs/api`; the machine-readable contract is `/api/openapi.json`.\n\n## Query documentation\n\nThe same corpus served at `/docs` is available through authenticated read endpoints:\n\n| Endpoint | Query | Response |\n| --- | --- | --- |\n| `GET /api/documentation` | Optional `audience` | Version identity, page metadata and navigation |\n| `GET /api/documentation/search` | `q`, optional `audience`, `limit` | Ranked results with excerpts and anchors |\n| `GET /api/documentation/page` | `slug`, optional `anchor` | Markdown for a page or heading subtree |\n\n`audience` is `user`, `developer` or `all` (default). Search accepts 1–50 results and defaults to 10. A missing page or anchor returns 404; invalid input returns 400. Disabling documentation also disables these endpoints.\n\n```bash\ncurl --fail --get http://127.0.0.1:8081/api/documentation/search \\\n --data-urlencode 'q=custom domain' \\\n --data-urlencode 'audience=user'\n```\n\nOn a protected panel, add an Authorization header using your Portta token. Every response identifies the corpus version and hash. The OpenAPI document describes the complete schemas.\n","text":"Use the Portta API API contract The panel publishes an OpenAPI 3.1 contract athttp://127.0.0.1:8081/api/openapi.json. It is generated from the same routeregistrations and Zod schemas the server and UI use: parameters, requestbodies, response shapes, status codes, read-only refusals and the SSE payloadare all part of the document. It declares the host-scoped Portta session andBearer tokens used by CLI and agent clients. http://127.0.0.1:8081/docs/api renders that document: operations grouped bytag, resolved schemas for parameters, request bodies and responses, thedeclared security schemes, and a console. The console executes a GET on a click. A POST, PUT, PATCH or DELETEsays what it is about to send and waits for a second, explicit confirmation,because it is a real request against this panel. Read-only mode and thesame-origin write guard come back as the API's own error payload rather than asa generic failure, so a refusal reads as a refusal. It is enabled by default only while the panel stays on loopback. A routed panelreturns 404 unless PORTTA_RUNTIME_API_DOCS=true explicitly opts in. The JSONcontract stays available because a caller that reached the API can alreadyinspect it. packages/contracts/openapi.json is checked in so an API change is visible inreview. npm run openapi:check --workspace=portta-contracts regenerates it inmemory and fails on byte-level drift. Adding or changing a route thereforerequires updating its attached description and runningnpm run openapi --workspace=portta-contracts. See Authentication for personal API tokens. The endpoint reference is served by this panel at /docs/api; the machine-readable contract is /api/openapi.json. Query documentation The same corpus served at /docs is available through authenticated read endpoints: Endpoint Query Response GET /api/documentation Optional audience Version identity, page metadata and navigation GET /api/documentation/search q, optional audience, limit Ranked results with excerpts and anchors GET /api/documentation/page slug, optional anchor Markdown for a page or heading subtree audience is user, developer or all (default). Search accepts 1–50 results and defaults to 10. A missing page or anchor returns 404; invalid input returns 400. Disabling documentation also disables these endpoints. On a protected panel, add an Authorization header using your Portta token. Every response identifies the corpus version and hash. The OpenAPI document describes the complete schemas.","headings":[{"id":"use-the-portta-api","text":"Use the Portta API","level":1,"line":0},{"id":"api-contract","text":"API contract","level":3,"line":2},{"id":"query-documentation","text":"Query documentation","level":2,"line":35}],"kind":"markdown"},{"slug":"panel-settings","title":"Configure the panel","description":"Configure gateway and panel settings through the interface.","source":"docs/product/guides/panel-settings.md","audience":"user","section":"Guides","category":"Maintenance and troubleshooting","url":"/docs/panel-settings","markdown":"# Configure the panel\n\n## Gateway\n\nComponent states, versions, the profile, diagnostics, and logs for Traefik, the\nsocket proxy and Tailscale.\n\n**Diagnostics are not `portta doctor`.** They are the checks a container\ncan make honestly: components present and healthy, the shared network, services\nthat opted into Traefik but never joined it, hostname collisions, port\nconflicts, stale bridges, unhealthy containers, and configuration that would\nrefuse to start. `doctor` runs on the host and additionally sees `PATH`,\nlistening sockets, DNS resolution and certificate files, which this process\ncannot see truthfully. The panel says so and points at the command.\n\n![The Gateway page: component states, versions and profile, the diagnostics it just ran, and Traefik's recent log lines](../../images/panel-gateway.png)\n\n\n## Settings\n\nSettings is a place with six sections, and which of them somebody sees depends\non what they hold. The rail lists only the ones they can open; `/settings`\nitself redirects to the first of them, so an owner lands on General and a viewer\nlands on their own tokens. A panel in `open` mode has no accounts, so Users,\nAPI tokens, Security and Audit are not offered at all — and a bookmark into one\nof them says the panel is local rather than showing an empty table.\n\n| Section | What it is | Who has it |\n|---|---|---|\n| General | How Portta names projects, who can reach them, and how this panel is reached | `settings:read` |\n| Users | Accounts, roles, Project access, ownership | `user:list` |\n| API tokens | The credentials that are not a browser | `token:read` |\n| Security | Your own password, second factor and sessions | anybody signed in |\n| Integrations | GitHub: the connection and its keys | `github:read` |\n| Audit | Who did what, newest first | `audit:read` |\n\n**General** is the settings people actually change. The groups follow three\ndecisions that stay independent: how projects are named, who can reach Traefik,\nand how this panel is reached. The conceptual map is\n[Addresses and access](../concepts/addresses-and-access.md). Each group has a stable\ndeep link, such as `/settings/general/tls` or\n`/settings/general/project-access`. Moving between groups keeps one shared\ndraft; badges identify unsaved work in another group and Save writes every\nchanged key in one transaction. A key that is not in the catalogue cannot be\nread or written through the API, whatever a request asks for.\n\nGateway, public access and VPN are one **Project access** group. The form writes\ntheir existing environment keys together so an operator cannot select a public\nprofile while leaving the public access decision or bind address behind.\n\nThe Traefik group shows the dashboard's status, every address that applies,\nand an Open action that is enabled only when an endpoint is usable. The\ndashboard stays on loopback under the normal host attachment: it has no login\nof its own. The panel warns when a Tailscale attachment also exposes it on the\ntailnet. Changing\n`PORTTA_DASHBOARD` needs the gateway recreated; the apply bar at the bottom\nis how that happens.\n\nThe Panel group also carries **what a local agent may do**: the\n`agentPermissions` setting, ticked one permission at a time, with the default\nin force until somebody narrows it. It is a ceiling over a request that\nannounces itself with `X-Portta-Actor` — it can only take away from what the\nperson behind it holds, never add.\n\n**Users** lists who can sign in with their role, whether the account is usable,\nand the Projects it reaches. Creating one hands over the first password on the\nspot: this panel sends no email. The row menu carries the role, a password\nreset, Project access, the open sessions, the ban and the removal — each of\nthem absent rather than disabled when the rule behind it would refuse\n([Authentication](authentication.md#the-rules-a-role-cannot-express)). Removing\nasks for the email to be typed. Transferring ownership is offered to the owner\nalone, and never on their own row.\n\n**API tokens** shows yours by default; an administrator can switch to\neverybody's. A new token's secret appears once, in a dialog that does not close\non an escape key: the panel keeps a hash, so a lost secret means making another\ntoken. Revoking says what stops working before it does it.\n\n**Security** is your own account. Changing your password signs you out of every\nother browser. Turning on a second factor asks for your password, shows the QR\ncode (and the secret, for an app that cannot scan it), verifies one code from\nthe app, and then shows the backup codes once. The session list marks the\nbrowser you are reading it in and signs the others out one at a time.\n\n**Audit** is who did what: accounts, roles, tokens, Project membership,\nsettings, and every lifecycle operation on an environment or a container.\nNewest first, filtered by account, paged backwards. Development activity —\ntasks, work sessions, commits — is not in it and lives on the Activity page\ninstead, and nothing that authenticates anything is in it either\n([security](../concepts/security.md#the-audit-log)).\n\n![The Gateway settings group: a stable group navigation beside each field, the key it writes and what it means](../../images/panel-settings.png)\n","text":"Configure the panel Gateway Component states, versions, the profile, diagnostics, and logs for Traefik, thesocket proxy and Tailscale. Diagnostics are not portta doctor. They are the checks a containercan make honestly: components present and healthy, the shared network, servicesthat opted into Traefik but never joined it, hostname collisions, portconflicts, stale bridges, unhealthy containers, and configuration that wouldrefuse to start. doctor runs on the host and additionally sees PATH,listening sockets, DNS resolution and certificate files, which this processcannot see truthfully. The panel says so and points at the command. The Gateway page: component states, versions and profile, the diagnostics it just ran, and Traefik's recent log lines Settings Settings is a place with six sections, and which of them somebody sees dependson what they hold. The rail lists only the ones they can open; /settingsitself redirects to the first of them, so an owner lands on General and a viewerlands on their own tokens. A panel in open mode has no accounts, so Users,API tokens, Security and Audit are not offered at all — and a bookmark into oneof them says the panel is local rather than showing an empty table. Section What it is Who has it General How Portta names projects, who can reach them, and how this panel is reached settings:read Users Accounts, roles, Project access, ownership user:list API tokens The credentials that are not a browser token:read Security Your own password, second factor and sessions anybody signed in Integrations GitHub: the connection and its keys github:read Audit Who did what, newest first audit:read General is the settings people actually change. The groups follow threedecisions that stay independent: how projects are named, who can reach Traefik,and how this panel is reached. The conceptual map isAddresses and access. Each group has a stabledeep link, such as /settings/general/tls or/settings/general/project-access. Moving between groups keeps one shareddraft; badges identify unsaved work in another group and Save writes everychanged key in one transaction. A key that is not in the catalogue cannot beread or written through the API, whatever a request asks for. Gateway, public access and VPN are one Project access group. The form writestheir existing environment keys together so an operator cannot select a publicprofile while leaving the public access decision or bind address behind. The Traefik group shows the dashboard's status, every address that applies,and an Open action that is enabled only when an endpoint is usable. Thedashboard stays on loopback under the normal host attachment: it has no loginof its own. The panel warns when a Tailscale attachment also exposes it on thetailnet. ChangingPORTTA_DASHBOARD needs the gateway recreated; the apply bar at the bottomis how that happens. The Panel group also carries what a local agent may do: theagentPermissions setting, ticked one permission at a time, with the defaultin force until somebody narrows it. It is a ceiling over a request thatannounces itself with X-Portta-Actor — it can only take away from what theperson behind it holds, never add. Users lists who can sign in with their role, whether the account is usable,and the Projects it reaches. Creating one hands over the first password on thespot: this panel sends no email. The row menu carries the role, a passwordreset, Project access, the open sessions, the ban and the removal — each ofthem absent rather than disabled when the rule behind it would refuse(Authentication). Removingasks for the email to be typed. Transferring ownership is offered to the owneralone, and never on their own row. API tokens shows yours by default; an administrator can switch toeverybody's. A new token's secret appears once, in a dialog that does not closeon an escape key: the panel keeps a hash, so a lost secret means making anothertoken. Revoking says what stops working before it does it. Security is your own account. Changing your password signs you out of everyother browser. Turning on a second factor asks for your password, shows the QRcode (and the secret, for an app that cannot scan it), verifies one code fromthe app, and then shows the backup codes once. The session list marks thebrowser you are reading it in and signs the others out one at a time. Audit is who did what: accounts, roles, tokens, Project membership,settings, and every lifecycle operation on an environment or a container.Newest first, filtered by account, paged backwards. Development activity —tasks, work sessions, commits — is not in it and lives on the Activity pageinstead, and nothing that authenticates anything is in it either(security). The Gateway settings group: a stable group navigation beside each field, the key it writes and what it means","headings":[{"id":"configure-the-panel","text":"Configure the panel","level":1,"line":0},{"id":"gateway","text":"Gateway","level":2,"line":2},{"id":"settings","text":"Settings","level":2,"line":18}],"kind":"markdown"},{"slug":"update","title":"Update Portta","description":"Update an installation while preserving its configuration and state.","source":"docs/product/guides/update.md","audience":"user","section":"Guides","category":"Maintenance and troubleshooting","url":"/docs/update","markdown":"# Update Portta\n\n## Updating\n\nRun the installer again.\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/fabioassuncao/portta/main/install.sh | bash\n```\n\nIt finds the existing installation, keeps every answer already recorded, pulls\nthe new images, and recreates. It never regenerates the panel database\npassword, the session signing secret, the ACME material or the Tailscale\nidentity, and it never overwrites `.env`, `state/`, `config/tls/`, or a file\nthat already exists in `config/traefik/dynamic/`. Panel accounts live in the\ndatabase, which an update never touches.\n\nTo see what would change without changing it:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/fabioassuncao/portta/main/install.sh | bash -s -- --pull-only\n```\n","text":"Update Portta Updating Run the installer again. It finds the existing installation, keeps every answer already recorded, pullsthe new images, and recreates. It never regenerates the panel databasepassword, the session signing secret, the ACME material or the Tailscaleidentity, and it never overwrites .env, state/, config/tls/, or a filethat already exists in config/traefik/dynamic/. Panel accounts live in thedatabase, which an update never touches. To see what would change without changing it:","headings":[{"id":"update-portta","text":"Update Portta","level":1,"line":0},{"id":"updating","text":"Updating","level":2,"line":2}],"kind":"markdown"},{"slug":"backup-restore","title":"Back up and restore the panel","description":"Back up and restore panel decisions and credentials.","source":"docs/product/guides/backup-restore.md","audience":"user","section":"Guides","category":"Maintenance and troubleshooting","url":"/docs/backup-restore","markdown":"# Back up and restore the panel\n\nBack up the panel database and its installation configuration together.\n\n> [!CAUTION]\n> Restoring replaces existing database objects. Confirm the target installation and retain a current backup before continuing.\n\n## Prerequisites\n\nUse the CLI against the intended Portta installation with Docker available.\nThe database clients run in an ephemeral toolbox container; the host does not\nneed `psql`. Credentials come from the installation environment.\n\nCheck database health before continuing:\n\n```bash\nportta db status\n```\n\nThese commands operate on the **panel database**. For a Project's own\nPostgreSQL, use [Connect to project databases](database-access.md).\n\n## Create a backup\n\nWrite a PostgreSQL custom-format archive to a new backup file:\n\n```bash\nportta db dump > portta.dump\n```\n\nThe command writes only the archive to stdout. Retain the installation's `.env`\nwith the backup: its credentials are not part of the archive. Store both with\nrestricted access. A successful command produces the archive without changing\nrunning database objects.\n\n## Restore a backup\n\nConfirm the target installation and create a current backup before replacing\nits state. Restore the saved archive and accept the CLI confirmation:\n\n```bash\nportta db restore portta.dump\n```\n\nRestore uses `--clean --if-exists` and ownership-neutral objects. It stops on\nan error; a failed restore may already have changed database objects. Keep the\nprevious backup until you have verified the restored installation.\n\nAfter a successful restore, open the panel and verify its Projects, users and\nsettings. For automation reading the archive from stdin, confirmation must be\nexplicitly disabled with `portta --yes db restore < portta.dump`.\n\n## Configuration and an existing volume\n\n`PORTTA_RUNTIME_DB_MODE=managed` uses internal DNS `db` and port `5432`; neither\nis a pretend configurable setting. `PORTTA_RUNTIME_DB_USER`, `_NAME` and\n`_PASSWORD` are shared by PostgreSQL and the application's URL resolver. The\npassword is generated into `.env` once. It is never generated inside PostgreSQL\nor discovered from a different container. The database has no host port.\n\n`PORTTA_RUNTIME_DB_MODE=external` requires `PORTTA_RUNTIME_DATABASE_URL`.\nThe managed fields are inactive, no local `db` is started, and readiness comes\nfrom the application's authenticated connection and migrations. Administrative\nclients use the same resolver, running in the toolbox on the gateway network.\nClient TLS file paths must be available inside the toolbox; they are not mounted\nfrom arbitrary host paths automatically.\n\nChanging `.env` does not modify an initialized PostgreSQL cluster. The installer\nnever issues an automatic `ALTER USER` or deletes a volume to make a password\nwork. An incompatible credential prevents the panel from starting. Recover the\noriginal `.env`/backup first. For deliberate password rotation, connect using the\ncurrent credential (`portta db shell`), use psql's interactive `\\password` for\nthe configured role, update `PORTTA_RUNTIME_DB_PASSWORD` on the host, and recreate\nthe panel/database containers with `portta up`. A changed database or role name\nrequires explicit PostgreSQL administration or dump/restore, not new defaults.\n\nSee [Persistence](../concepts/persistence.md) for state ownership and lifecycle, or [Develop the database schema](../../development/database-development.md) for schema migrations.\n","text":"Back up and restore the panel Back up the panel database and its installation configuration together. [!CAUTION]Restoring replaces existing database objects. Confirm the target installation and retain a current backup before continuing. Prerequisites Use the CLI against the intended Portta installation with Docker available.The database clients run in an ephemeral toolbox container; the host does notneed psql. Credentials come from the installation environment. Check database health before continuing: These commands operate on the panel database. For a Project's ownPostgreSQL, use Connect to project databases. Create a backup Write a PostgreSQL custom-format archive to a new backup file: The command writes only the archive to stdout. Retain the installation's .envwith the backup: its credentials are not part of the archive. Store both withrestricted access. A successful command produces the archive without changingrunning database objects. Restore a backup Confirm the target installation and create a current backup before replacingits state. Restore the saved archive and accept the CLI confirmation: Restore uses --clean --if-exists and ownership-neutral objects. It stops onan error; a failed restore may already have changed database objects. Keep theprevious backup until you have verified the restored installation. After a successful restore, open the panel and verify its Projects, users andsettings. For automation reading the archive from stdin, confirmation must beexplicitly disabled with portta --yes db restore < portta.dump. Configuration and an existing volume PORTTA_RUNTIME_DB_MODE=managed uses internal DNS db and port 5432; neitheris a pretend configurable setting. PORTTA_RUNTIME_DB_USER, _NAME and_PASSWORD are shared by PostgreSQL and the application's URL resolver. Thepassword is generated into .env once. It is never generated inside PostgreSQLor discovered from a different container. The database has no host port. PORTTA_RUNTIME_DB_MODE=external requires PORTTA_RUNTIME_DATABASE_URL.The managed fields are inactive, no local db is started, and readiness comesfrom the application's authenticated connection and migrations. Administrativeclients use the same resolver, running in the toolbox on the gateway network.Client TLS file paths must be available inside the toolbox; they are not mountedfrom arbitrary host paths automatically. Changing .env does not modify an initialized PostgreSQL cluster. The installernever issues an automatic ALTER USER or deletes a volume to make a passwordwork. An incompatible credential prevents the panel from starting. Recover theoriginal .env/backup first. For deliberate password rotation, connect using thecurrent credential (portta db shell), use psql's interactive \\password forthe configured role, update PORTTA_RUNTIME_DB_PASSWORD on the host, and recreatethe panel/database containers with portta up. A changed database or role namerequires explicit PostgreSQL administration or dump/restore, not new defaults. See Persistence for state ownership and lifecycle, or Develop the database schema for schema migrations.","headings":[{"id":"back-up-and-restore-the-panel","text":"Back up and restore the panel","level":1,"line":0},{"id":"prerequisites","text":"Prerequisites","level":2,"line":7},{"id":"create-a-backup","text":"Create a backup","level":2,"line":22},{"id":"restore-a-backup","text":"Restore a backup","level":2,"line":35},{"id":"configuration-and-an-existing-volume","text":"Configuration and an existing volume","level":2,"line":52}],"kind":"markdown"},{"slug":"troubleshooting","title":"Troubleshooting","description":"Diagnose routes, DNS, certificates and container reachability.","source":"docs/product/guides/troubleshooting.md","audience":"user","section":"Guides","category":"Maintenance and troubleshooting","url":"/docs/troubleshooting","markdown":"# Troubleshooting\n\nStart here:\n\n```bash\nportta doctor\n```\n\nIt checks the runtime, networks, component health, exposure, DNS, TLS, route\ncollisions and label mistakes, and prints a suggested fix for anything that\nfails. It never changes anything.\n\n---\n\n## A hostname does not resolve\n\n```bash\nping demo-a-web.localhost\n```\n\n`*.localhost` is required to resolve to loopback by RFC 6761 and needs no\nconfiguration. If it fails, the resolver in play does not implement it, most\noften an old Go binary, a JVM HTTP client, or musl inside an Alpine container.\n\nPoint `PORTTA_DOMAIN` at a real domain resolving to `127.0.0.1`, or reach\nthe service by container name over the shared network instead.\n\n## 404 from the gateway\n\nTraefik answered, but no router matched. In order of likelihood:\n\n```bash\nportta urls # is the hostname listed at all?\n```\n\n**Not listed.** The container did not opt in. Check it has\n`traefik.enable=true`, and that it is actually running:\n\n```bash\ndocker inspect <container> --format '{{json .Config.Labels}}' | jq\n```\n\n**Listed but 404.** Usually a stale route. Traefik discovers asynchronously;\ngive it a couple of seconds after `up`. If it persists:\n\n```bash\nPORTTA_LOG_LEVEL=DEBUG portta up local\nportta logs traefik\n```\n\n**A literal `${...}` in a label.** The labels were written in map form.\nCompose interpolates `${VAR}` inside a *list* entry but not inside a mapping\nkey. Rewrite as a list:\n\n```yaml\nlabels:\n - \"traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=3000\"\n```\n\n`doctor` reports this specifically.\n\n## 502 / 504 from the gateway\n\nThe router matched but Traefik could not reach the backend.\n\n**Wrong port.** Traefik guesses from the image's `EXPOSE` when no port is\ndeclared. If your app listens on 3000 but the image exposes 80, say so:\n\n```yaml\n- \"traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=3000\"\n```\n\n**Not on the shared network.** The service must join `portta` as well as\nits own network:\n\n```bash\ndocker inspect <container> --format '{{json .NetworkSettings.Networks}}' | jq 'keys'\n```\n\n**Wrong network chosen.** A multi-homed container has two addresses; if Traefik\npicked the private one it cannot reach it. Add:\n\n```yaml\n- \"traefik.docker.network=portta\"\n```\n\n**The app is bound to `127.0.0.1` inside its container**, so nothing outside\nthe container can reach it. It must listen on `0.0.0.0`.\n\n## One project receives another project's traffic\n\nTraefik service names are one flat namespace across the host. Two projects\ndeclaring `traefik.http.services.web...` are merged into a single load\nbalancer. Prefix with the namespace:\n\n```yaml\n- \"traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=3000\"\n```\n\n`doctor` reports both this and hostname collisions between projects whose names\ndiffer only in punctuation (`foo_bar` and `foo-bar` normalise identically).\n\n## Port 80 or 443 is already in use\n\n```bash\nlsof -nP -iTCP:80 -sTCP:LISTEN # macOS\nss -ltnp sport = :80 # Linux\n```\n\nStop the other process, or set `PORTTA_HTTP_PORT=8080` and use\n`http://demo-a-web.localhost:8080`.\n\n## Traefik keeps restarting\n\nAlmost always a bad static configuration key. Traefik names the exact node it\ncould not decode:\n\n```bash\ndocker logs portta-traefik-1 2>&1 | tail -5\n# failed to decode configuration from environment variables: field not found, node: ...\n```\n\nNested keys are the usual cause: `aliasHeadersStrategy` lives under `http`, so\nthe variable is `TRAEFIK_ENTRYPOINTS_WEB_HTTP_ALIASHEADERSSTRATEGY`, not\n`TRAEFIK_ENTRYPOINTS_WEB_ALIASHEADERSSTRATEGY`.\n\n## The socket proxy is unhealthy\n\nIts entrypoint renders a config into `/tmp`, and the container runs\n`read_only: true`. It needs the tmpfs mounts declared in\n`docker/compose/compose.yaml`:\n\n```bash\ndocker logs portta-socket-proxy-1\n# can't create /tmp/haproxy.cfg: Read-only file system\n```\n\n## Postgres 18 will not start\n\nPostgreSQL 18+ images store data in a major-version subdirectory and refuse to\nstart when `.../data` is mounted directly. Mount the parent:\n\n```yaml\nvolumes:\n - pgdata:/var/lib/postgresql # not /var/lib/postgresql/data\n```\n\n## `portta down` did not remove the shared network\n\nThat is intentional. Other projects are attached to it, and the gateway never\nremoves a network other people are using. Remove it deliberately once nothing\nis attached:\n\n```bash\ndocker network inspect portta --format '{{ len .Containers }}'\ndocker network rm portta\n```\n\n## `just dev` or `just reset` seems to hang\n\nIt is almost always a build, not a hang. The first run in a checkout, and any\nrun after a dependency change, builds the panel image — two `npm ci`, three\nworkspace builds and a docs render — which takes minutes.\n\nThe CLI says so now. A phase announces itself, a build streams its own\nprogress, and anything quiet reports how long it has been going:\n\n```text\nwait still running: docker compose up --build (2m10s)\n```\n\nIf you want to see what it is actually doing, or to confirm it is moving:\n\n```bash\njust dev --verbose # stream every child process\ndocker buildx du # the build cache, from another terminal\ndocker compose -f docker/compose/compose.yaml ps\n```\n\n`Ctrl-C` during a build is safe: BuildKit keeps the layers it has finished, so\nthe next run resumes rather than starting over.\n\n## Everything looks right and it still does not work\n\n```bash\nportta doctor --json | jq '.checks[] | select(.status != \"pass\")'\nportta inspect\ndocker logs portta-traefik-1 --tail 50\n```\n\nInclude those three in a bug report.\n","text":"Troubleshooting Start here: It checks the runtime, networks, component health, exposure, DNS, TLS, routecollisions and label mistakes, and prints a suggested fix for anything thatfails. It never changes anything. A hostname does not resolve *.localhost is required to resolve to loopback by RFC 6761 and needs noconfiguration. If it fails, the resolver in play does not implement it, mostoften an old Go binary, a JVM HTTP client, or musl inside an Alpine container. Point PORTTA_DOMAIN at a real domain resolving to 127.0.0.1, or reachthe service by container name over the shared network instead. 404 from the gateway Traefik answered, but no router matched. In order of likelihood: Not listed. The container did not opt in. Check it hastraefik.enable=true, and that it is actually running: Listed but 404. Usually a stale route. Traefik discovers asynchronously;give it a couple of seconds after up. If it persists: A literal ${...} in a label. The labels were written in map form.Compose interpolates ${VAR} inside a list entry but not inside a mappingkey. Rewrite as a list: doctor reports this specifically. 502 / 504 from the gateway The router matched but Traefik could not reach the backend. Wrong port. Traefik guesses from the image's EXPOSE when no port isdeclared. If your app listens on 3000 but the image exposes 80, say so: Not on the shared network. The service must join portta as well asits own network: Wrong network chosen. A multi-homed container has two addresses; if Traefikpicked the private one it cannot reach it. Add: The app is bound to 127.0.0.1 inside its container, so nothing outsidethe container can reach it. It must listen on 0.0.0.0. One project receives another project's traffic Traefik service names are one flat namespace across the host. Two projectsdeclaring traefik.http.services.web... are merged into a single loadbalancer. Prefix with the namespace: doctor reports both this and hostname collisions between projects whose namesdiffer only in punctuation (foo_bar and foo-bar normalise identically). Port 80 or 443 is already in use Stop the other process, or set PORTTA_HTTP_PORT=8080 and usehttp://demo-a-web.localhost:8080. Traefik keeps restarting Almost always a bad static configuration key. Traefik names the exact node itcould not decode: Nested keys are the usual cause: aliasHeadersStrategy lives under http, sothe variable is TRAEFIK_ENTRYPOINTS_WEB_HTTP_ALIASHEADERSSTRATEGY, notTRAEFIK_ENTRYPOINTS_WEB_ALIASHEADERSSTRATEGY. The socket proxy is unhealthy Its entrypoint renders a config into /tmp, and the container runsread_only: true. It needs the tmpfs mounts declared indocker/compose/compose.yaml: Postgres 18 will not start PostgreSQL 18+ images store data in a major-version subdirectory and refuse tostart when .../data is mounted directly. Mount the parent: portta down did not remove the shared network That is intentional. Other projects are attached to it, and the gateway neverremoves a network other people are using. Remove it deliberately once nothingis attached: just dev or just reset seems to hang It is almost always a build, not a hang. The first run in a checkout, and anyrun after a dependency change, builds the panel image — two npm ci, threeworkspace builds and a docs render — which takes minutes. The CLI says so now. A phase announces itself, a build streams its ownprogress, and anything quiet reports how long it has been going: If you want to see what it is actually doing, or to confirm it is moving: Ctrl-C during a build is safe: BuildKit keeps the layers it has finished, sothe next run resumes rather than starting over. Everything looks right and it still does not work Include those three in a bug report.","headings":[{"id":"troubleshooting","text":"Troubleshooting","level":1,"line":0},{"id":"a-hostname-does-not-resolve","text":"A hostname does not resolve","level":2,"line":14},{"id":"404-from-the-gateway","text":"404 from the gateway","level":2,"line":27},{"id":"502--504-from-the-gateway","text":"502 / 504 from the gateway","level":2,"line":61},{"id":"one-project-receives-another-projects-traffic","text":"One project receives another project's traffic","level":2,"line":89},{"id":"port-80-or-443-is-already-in-use","text":"Port 80 or 443 is already in use","level":2,"line":102},{"id":"traefik-keeps-restarting","text":"Traefik keeps restarting","level":2,"line":112},{"id":"the-socket-proxy-is-unhealthy","text":"The socket proxy is unhealthy","level":2,"line":126},{"id":"postgres-18-will-not-start","text":"Postgres 18 will not start","level":2,"line":137},{"id":"portta-down-did-not-remove-the-shared-network","text":"portta down did not remove the shared network","level":2,"line":147},{"id":"just-dev-or-just-reset-seems-to-hang","text":"just dev or just reset seems to hang","level":2,"line":158},{"id":"everything-looks-right-and-it-still-does-not-work","text":"Everything looks right and it still does not work","level":2,"line":182}],"kind":"markdown"},{"slug":"configuration","title":"Configuration reference","description":"Look up installation variables, defaults and configuration ownership.","source":"docs/product/reference/configuration.md","audience":"user","section":"Reference","category":"","url":"/docs/configuration","markdown":"# Configuration reference\n\nLook up the installation variables, defaults and their meaning. The installation `.env` stores values; `.env.example` defines supported variables. Use `portta inspect` to inspect resolved configuration without printing secrets.\n\n## Common\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `PORTTA_PROFILE` | `local` | Default profile for `up` |\n| `PORTTA_PROJECT_NAME` | `portta` | Compose project name of the gateway itself |\n| `PORTTA_NETWORK` | `portta` | Shared external network |\n| `PORTTA_CONTROL_NETWORK` | `portta-control` | Internal Traefik ↔ socket proxy network |\n| `PORTTA_ACCESS_NETWORK` | `portta-access` | Network for persistent TCP forwarders |\n| `PORTTA_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARN`, `ERROR` |\n| `PORTTA_ACCESS_LOG` | `false` | Traefik access logs, useful when a route misbehaves |\n\n`PORTTA_PROJECT_NAME` is load-bearing: ownership checks use it to tell\ngateway containers from everything else. Changing it orphans the running stack.\n\n## Local profile\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `PORTTA_DOMAIN` | `localhost` | Base domain for generated hostnames |\n| `PORTTA_BIND_ADDRESS` | `127.0.0.1` | Host interface Traefik publishes on |\n| `PORTTA_HTTP_PORT` | `80` | Host port for HTTP |\n| `PORTTA_HTTPS_PORT` | `443` | Host port for HTTPS |\n\n`PORTTA_BIND_ADDRESS` is the single most security-relevant setting here.\nLoopback keeps the gateway invisible to everyone else on your network;\n`doctor` fails if the local profile is bound to anything else.\n\nIf 80 is already taken, changing `PORTTA_HTTP_PORT` to, say, `8080` means\nURLs become `http://demo-a-web.localhost:8080`.\n\n## Header aliasing\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `PORTTA_ALIAS_HEADERS_STRATEGY` | `keep` | `keep`, `delete` or `reject` |\n\nHeaders whose names contain characters outside `[A-Za-z0-9-]` can alias a\ncanonical header once a backend normalises them (`X_Auth_User` becoming\n`X-Auth-User` in CGI, WSGI, PHP or nginx), which lets a client spoof headers\nTraefik manages.\n\n`keep` is Traefik's default and is fine behind loopback or a VPN. `delete`\nstrips them, but also strips *legitimate* underscore headers, which can break\nan app in a confusing way, so it is opt-in locally and applied automatically\nby the public profile.\n\n## Dashboard\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `PORTTA_DASHBOARD` | `false` | Enable Traefik's dashboard |\n| `PORTTA_DASHBOARD_BIND_ADDRESS` | `127.0.0.1` | Interface for the dashboard port |\n| `PORTTA_DASHBOARD_PORT` | `8080` | Host port |\n\nThe loopback path exposes your full routing table on its own port, never\nthrough `web`/`websecure`, so it can never appear under the public wildcard\ndomain. `doctor` still fails if that port is bound anywhere but loopback.\n\nThe dashboard is always loopback-only. It has no routed access mode.\n\n## Databases by hostname\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `PORTTA_TCP` | `false` | Publish one entrypoint per protocol and route on the hostname |\n| `PORTTA_TCP_POSTGRES_PORT` | `5432` | Host port for the PostgreSQL entrypoint |\n| `PORTTA_TCP_REDIS_PORT` | `6379` | Host port for the Redis entrypoint |\n\nOff by default, and opt-in twice: the gateway publishes the entrypoints, and a\nproject's datastore has to carry the router labels before anything routes to\nit. Refused on the `remote-public` profile. TLS is required, because the\nhostname travels in the TLS handshake. PostgreSQL and Redis work; MySQL cannot.\nSee [Configure TCP routing](../guides/tcp-routing.md).\n\n## Web panel\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `PORTTA_WEB` | `false` | Start the administration panel with the gateway |\n| `PORTTA_WEB_BIND_ADDRESS` | `127.0.0.1` | Interface the panel is published on |\n| `PORTTA_WEB_PORT` | `8081` | Host port |\n| `PORTTA_WEB_EXPOSE` | `local` | `local`, `tailscale`, `public`, `vpn`, or `domain` to route it on the gateway's domain over HTTPS |\n| `PORTTA_PANEL_ADVERTISED_HOST` | derived | The hostname `domain` routes on, and the address a human types |\n| `PORTTA_WEB_HOST` | `portta-web` | Hostname label used by `vpn` |\n| `PORTTA_WEB_READ_ONLY` | `false` | Refuse every mutating endpoint, whoever signed in |\n| `PORTTA_AUTH_MODE` | `disabled` | `disabled` answers everybody as the local operator and is allowed only on loopback; `required` makes people sign in |\n| `PORTTA_PANEL_URL` | `http://127.0.0.1:<port>` | The origin a browser reaches the panel on. Decides where sign-in redirects to and whether the session cookie may be `Secure` |\n| `PORTTA_PANEL_TRUSTED_ORIGINS` | empty | Other origins a browser may sign in from, comma-separated. Loopback and the panel URL are always trusted |\n| `PORTTA_AUTH_SIGNIN_ATTEMPTS` | `5` | Sign-in attempts one address gets every ten minutes. 3–100; anything else reads as the default |\n| `PORTTA_WEB_DEV` | `false` | Development mode: bind-mounted panel and ForwardAuth sources reload on change; the dev image only supplies dependencies |\n| `PORTTA_WEB_NETWORK` | `portta-web` | The panel's own internal control network |\n| `PORTTA_WEB_USER` | owner of `.env` | User the panel container runs as, so Settings can save |\n| `PORTTA_APPLY` | `false` | Prepare the applier the panel may start to run `portta up` ([ADR 0026](../../development/adr/0026-applying-settings-from-the-panel.md)) |\n| `PORTTA_RUNNER` | `false` | Prepare the project runner the panel may start to drive Compose for one project ([ADR 0030](../../development/adr/0030-the-panel-and-a-project-lifecycle.md)) |\n| `PORTTA_PROJECTS_HOME` | `~/projects` (`/srv/projects` as root) | Projects Home, the directory managed Projects live under. `portta repos scan` reads it on the host, and development/demo commands discover `portta-demo-*` repositories there. The panel only receives the path as a string to classify locations, and never mounts it ([ADR 0031](../../development/adr/0031-projects-home-and-project.md), [ADR 0044](../../development/adr/0044-example-projects-live-in-projects-home.md)) |\n| `PORTTA_DB_NETWORK` | `portta-data` | Internal panel-to-PostgreSQL network |\n| `PORTTA_DB_VOLUME` | `portta-db` | Named volume holding panel data |\n| `PORTTA_RUNTIME_DB_PASSWORD` | generated | **Secret.** Panel PostgreSQL credential |\n| `PORTTA_RUNTIME_DB_MODE` | `managed` | `managed` selects the private Compose database; `external` requires the URL below |\n| `PORTTA_RUNTIME_DB_NAME` | `portta` | Database initialized in a fresh managed volume |\n| `PORTTA_RUNTIME_DB_USER` | `portta` | Role initialized in a fresh managed volume |\n| `PORTTA_RUNTIME_DATABASE_URL` | empty | Explicit external connection URL; rejected when set in managed mode |\n| `PORTTA_AUTH_SECRET` | generated | **Secret.** Signs the panel's sessions and tokens, and the ForwardAuth process's host-scoped cookies. Rotating it signs everybody out of both |\n| `PORTTA_AUTH_IMAGE` | Portta release image | Image running the isolated auth process |\n| `PORTTA_RUNTIME_DOCS` | `true` | Serve this documentation at `/docs`, from the panel image. Static text with no host information in it, so a routed panel may serve it |\n| `PORTTA_RUNTIME_API_DOCS` | empty | Serve the API reference and its console at `/docs/api`. Empty means the safe default: on for loopback, off when routed |\n\nThe panel binds loopback by default, and `PORTTA_AUTH_MODE=disabled` is only\nallowed there: reaching a loopback panel already means having the machine, which\nis true of nothing else, so the panel refuses to start rather than warn. Every\nother access mode — `tailscale`, `public`, `vpn`, `domain` — requires\n`required`, and `vpn` is refused on the `remote-public` profile. On a Linux host set `PORTTA_WEB_USER` to\n`$(id -u):$(id -g)` if you want the Settings page to be able to write `.env`.\n\n`portta web up` sets these for you and generates the database credential and the\nsigning secret without printing either. PostgreSQL publishes no host port and is\na boot dependency: the panel remembers everything there, including who its users\nare. See [Use the web panel](../guides/web-ui.md), [Configure authentication](../guides/authentication.md) and\n[Persistence](../concepts/persistence.md).\n\n## TLS\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `TLS_ENABLED` | `false` | Master switch for HTTPS |\n| `TLS_MODE` | `local` | `local` (local CA) or `acme` (Let's Encrypt) |\n| `ACME_EMAIL` | — | Required when `TLS_MODE=acme` |\n| `ACME_CA_SERVER` | production LE | Point at staging while testing |\n| `ACME_CHALLENGE` | `dns` | `dns` (one wildcard, needs a credential) or `http` (one per hostname, needs `:80`) |\n| `ACME_DNS_PROVIDER` | `cloudflare` | lego provider name for DNS-01 |\n| `ACME_DNS_RESOLVERS` | `1.1.1.1:53,8.8.8.8:53` | Propagation checks |\n\nWildcard certificates require DNS-01; HTTP-01 cannot issue them, which is why\n`dns` is the default. A public gateway that would rather not hold a DNS\ncredential can set `ACME_CHALLENGE=http` and get a certificate per hostname\ninstead — see [DNS and TLS](../guides/dns-and-tls.md). Use `ACME_CA_SERVER` with the\nstaging endpoint while you get either working, because Let's Encrypt rate\nlimits are unforgiving.\n\n## Private access\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `TAILSCALE_ENABLED` | `false` | Run the Tailscale component |\n| `TAILSCALE_HOSTNAME` | `portta` | Node name on the tailnet |\n| `TS_AUTHKEY` | — | **Secret.** Prefer an ephemeral, tagged, pre-authorized key |\n| `TS_EXTRA_ARGS` | — | Extra flags for `tailscale up` |\n| `PRIVATE_DOMAIN` | — | Wildcard namespace served over the VPN |\n\n## Public access\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `PUBLIC_ENABLED` | `false` | Opt in to internet exposure |\n| `PUBLIC_DOMAIN` | — | Public wildcard, e.g. `dev.example.com` |\n\nOff by default and deliberately awkward to turn on. `portta public enable`\nprints exactly what will become reachable and asks for confirmation.\n\n## Cloudflare\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `CLOUDFLARE_ENABLED` | `false` | Use Cloudflare for DNS-01 |\n| `CF_DNS_API_TOKEN` | — | **Secret.** Scoped API Token |\n| `CLOUDFLARE_ZONE` | — | Target zone |\n\nUse a scoped token with `Zone:DNS:Edit` on the one zone. Never the Global API\nKey, which authenticates everything in the account and cannot be scoped.\n\n## Secrets\n\n`.env` is git-ignored, `bootstrap` writes it `0600`, `inspect` prints `<set>`\nrather than values, and lint fails on tracked auth keys or private keys. Gateway\nstate, including ACME material, lives under `state/`, which is also ignored.\n\n## Host metrics\n\n`portta host collect` writes `state/metrics/current.json` on the host. The\npanel only reads that file — it cannot see the real machine from inside its\ncontainer, and it never calls `systeminformation`. `portta up` and\n`portta web up` start a detached watcher that refreshes the snapshot every\nfive seconds. See [Host metrics](host-metrics.md).\n\nThere is no operator setting for collection. The panel's\n`PORTTA_RUNTIME_METRICS_DIR` is the mount path inside the container\n(default `/app/state/metrics`).\n\nHost tool readiness follows the same boundary. The CLI writes\n`state/environment/report.json`; the panel mounts it read-only at\n`/app/state/environment`. See [Host environment readiness](host-readiness.md).\n\n## Configuration ownership\n\nThe installation `.env` is the source of shared configuration and credentials.\n`.env.example` defines its structure, groups, comments and supported variables.\n`portta config prepare` creates or reconciles it without starting services;\n`bootstrap`, `dev`, `up`, `web up`, `setup` and the installer also prepare it.\n\nPersisted `.env` values **win over the inherited shell environment**. Explicit\nconfiguration commands write the file before resolving Compose. Runtime selectors\nsuch as `PORTTA_ROOT` and `PATH` are process inputs, not\ninstallation settings. Internal service ports and filesystem paths in containers\nare architectural constants. `PORTTA_VERSION` derives from the generated\nruntime version packaged with the installed CLI.\n\nPreparation fills absent keys from the template and generates absent/empty secrets\nonce. It keeps configured values, including deliberate empty optional fields.\nMissing keys are inserted near their template neighbours. Ordinary edits\nonly replace the requested values, preserving comments, order, spacing and line\nendings. Duplicate keys are rejected. Dotenv content is parsed, never executed;\nPortta treats values literally rather than evaluating `${OTHER_VARIABLE}`.\n\nCLI and panel share `portta-core`'s document editor. A `.env-lock/writer` directory serializes\nwrites across the host and panel; `.env-lock` is a shared mount, not image content.\nBackups and in-place writes preserve the file's inode and mode `0600`. A stale\nlock fails with a diagnostic; only remove it after verifying no writer is active.\n\nHow those values relate — project hostnames, public access, the panel URL,\nTraefik, TLS, VPN and DNS — is [Addresses and access](../concepts/addresses-and-access.md).\nThe Settings pages edit the same keys without asking you to think in variable\nnames.\n\n```bash\nportta inspect # what the CLI actually resolved (secrets shown as <set>)\n```\n","text":"Configuration reference Look up the installation variables, defaults and their meaning. The installation .env stores values; .env.example defines supported variables. Use portta inspect to inspect resolved configuration without printing secrets. Common Variable Default Meaning PORTTA_PROFILE local Default profile for up PORTTA_PROJECT_NAME portta Compose project name of the gateway itself PORTTA_NETWORK portta Shared external network PORTTA_CONTROL_NETWORK portta-control Internal Traefik ↔ socket proxy network PORTTA_ACCESS_NETWORK portta-access Network for persistent TCP forwarders PORTTA_LOG_LEVEL INFO DEBUG, INFO, WARN, ERROR PORTTA_ACCESS_LOG false Traefik access logs, useful when a route misbehaves PORTTA_PROJECT_NAME is load-bearing: ownership checks use it to tellgateway containers from everything else. Changing it orphans the running stack. Local profile Variable Default Meaning PORTTA_DOMAIN localhost Base domain for generated hostnames PORTTA_BIND_ADDRESS 127.0.0.1 Host interface Traefik publishes on PORTTA_HTTP_PORT 80 Host port for HTTP PORTTA_HTTPS_PORT 443 Host port for HTTPS PORTTA_BIND_ADDRESS is the single most security-relevant setting here.Loopback keeps the gateway invisible to everyone else on your network;doctor fails if the local profile is bound to anything else. If 80 is already taken, changing PORTTA_HTTP_PORT to, say, 8080 meansURLs become http://demo-a-web.localhost:8080. Header aliasing Variable Default Meaning PORTTA_ALIAS_HEADERS_STRATEGY keep keep, delete or reject Headers whose names contain characters outside [A-Za-z0-9-] can alias acanonical header once a backend normalises them (X_Auth_User becomingX-Auth-User in CGI, WSGI, PHP or nginx), which lets a client spoof headersTraefik manages. keep is Traefik's default and is fine behind loopback or a VPN. deletestrips them, but also strips legitimate underscore headers, which can breakan app in a confusing way, so it is opt-in locally and applied automaticallyby the public profile. Dashboard Variable Default Meaning PORTTA_DASHBOARD false Enable Traefik's dashboard PORTTA_DASHBOARD_BIND_ADDRESS 127.0.0.1 Interface for the dashboard port PORTTA_DASHBOARD_PORT 8080 Host port The loopback path exposes your full routing table on its own port, neverthrough web/websecure, so it can never appear under the public wildcarddomain. doctor still fails if that port is bound anywhere but loopback. The dashboard is always loopback-only. It has no routed access mode. Databases by hostname Variable Default Meaning PORTTA_TCP false Publish one entrypoint per protocol and route on the hostname PORTTA_TCP_POSTGRES_PORT 5432 Host port for the PostgreSQL entrypoint PORTTA_TCP_REDIS_PORT 6379 Host port for the Redis entrypoint Off by default, and opt-in twice: the gateway publishes the entrypoints, and aproject's datastore has to carry the router labels before anything routes toit. Refused on the remote-public profile. TLS is required, because thehostname travels in the TLS handshake. PostgreSQL and Redis work; MySQL cannot.See Configure TCP routing. Web panel Variable Default Meaning PORTTA_WEB false Start the administration panel with the gateway PORTTA_WEB_BIND_ADDRESS 127.0.0.1 Interface the panel is published on PORTTA_WEB_PORT 8081 Host port PORTTA_WEB_EXPOSE local local, tailscale, public, vpn, or domain to route it on the gateway's domain over HTTPS PORTTA_PANEL_ADVERTISED_HOST derived The hostname domain routes on, and the address a human types PORTTA_WEB_HOST portta-web Hostname label used by vpn PORTTA_WEB_READ_ONLY false Refuse every mutating endpoint, whoever signed in PORTTA_AUTH_MODE disabled disabled answers everybody as the local operator and is allowed only on loopback; required makes people sign in PORTTA_PANEL_URL http://127.0.0.1:<port> The origin a browser reaches the panel on. Decides where sign-in redirects to and whether the session cookie may be Secure PORTTA_PANEL_TRUSTED_ORIGINS empty Other origins a browser may sign in from, comma-separated. Loopback and the panel URL are always trusted PORTTA_AUTH_SIGNIN_ATTEMPTS 5 Sign-in attempts one address gets every ten minutes. 3–100; anything else reads as the default PORTTA_WEB_DEV false Development mode: bind-mounted panel and ForwardAuth sources reload on change; the dev image only supplies dependencies PORTTA_WEB_NETWORK portta-web The panel's own internal control network PORTTA_WEB_USER owner of .env User the panel container runs as, so Settings can save PORTTA_APPLY false Prepare the applier the panel may start to run portta up (ADR 0026) PORTTA_RUNNER false Prepare the project runner the panel may start to drive Compose for one project (ADR 0030) PORTTA_PROJECTS_HOME ~/projects (/srv/projects as root) Projects Home, the directory managed Projects live under. portta repos scan reads it on the host, and development/demo commands discover portta-demo-* repositories there. The panel only receives the path as a string to classify locations, and never mounts it (ADR 0031, ADR 0044) PORTTA_DB_NETWORK portta-data Internal panel-to-PostgreSQL network PORTTA_DB_VOLUME portta-db Named volume holding panel data PORTTA_RUNTIME_DB_PASSWORD generated Secret. Panel PostgreSQL credential PORTTA_RUNTIME_DB_MODE managed managed selects the private Compose database; external requires the URL below PORTTA_RUNTIME_DB_NAME portta Database initialized in a fresh managed volume PORTTA_RUNTIME_DB_USER portta Role initialized in a fresh managed volume PORTTA_RUNTIME_DATABASE_URL empty Explicit external connection URL; rejected when set in managed mode PORTTA_AUTH_SECRET generated Secret. Signs the panel's sessions and tokens, and the ForwardAuth process's host-scoped cookies. Rotating it signs everybody out of both PORTTA_AUTH_IMAGE Portta release image Image running the isolated auth process PORTTA_RUNTIME_DOCS true Serve this documentation at /docs, from the panel image. Static text with no host information in it, so a routed panel may serve it PORTTA_RUNTIME_API_DOCS empty Serve the API reference and its console at /docs/api. Empty means the safe default: on for loopback, off when routed The panel binds loopback by default, and PORTTA_AUTH_MODE=disabled is onlyallowed there: reaching a loopback panel already means having the machine, whichis true of nothing else, so the panel refuses to start rather than warn. Everyother access mode — tailscale, public, vpn, domain — requiresrequired, and vpn is refused on the remote-public profile. On a Linux host set PORTTA_WEB_USER to$(id -u):$(id -g) if you want the Settings page to be able to write .env. portta web up sets these for you and generates the database credential and thesigning secret without printing either. PostgreSQL publishes no host port and isa boot dependency: the panel remembers everything there, including who its usersare. See Use the web panel, Configure authentication andPersistence. TLS Variable Default Meaning TLS_ENABLED false Master switch for HTTPS TLS_MODE local local (local CA) or acme (Let's Encrypt) ACME_EMAIL — Required when TLS_MODE=acme ACME_CA_SERVER production LE Point at staging while testing ACME_CHALLENGE dns dns (one wildcard, needs a credential) or http (one per hostname, needs :80) ACME_DNS_PROVIDER cloudflare lego provider name for DNS-01 ACME_DNS_RESOLVERS 1.1.1.1:53,8.8.8.8:53 Propagation checks Wildcard certificates require DNS-01; HTTP-01 cannot issue them, which is whydns is the default. A public gateway that would rather not hold a DNScredential can set ACME_CHALLENGE=http and get a certificate per hostnameinstead — see DNS and TLS. Use ACME_CA_SERVER with thestaging endpoint while you get either working, because Let's Encrypt ratelimits are unforgiving. Private access Variable Default Meaning TAILSCALE_ENABLED false Run the Tailscale component TAILSCALE_HOSTNAME portta Node name on the tailnet TS_AUTHKEY — Secret. Prefer an ephemeral, tagged, pre-authorized key TS_EXTRA_ARGS — Extra flags for tailscale up PRIVATE_DOMAIN — Wildcard namespace served over the VPN Public access Variable Default Meaning PUBLIC_ENABLED false Opt in to internet exposure PUBLIC_DOMAIN — Public wildcard, e.g. dev.example.com Off by default and deliberately awkward to turn on. portta public enableprints exactly what will become reachable and asks for confirmation. Cloudflare Variable Default Meaning CLOUDFLARE_ENABLED false Use Cloudflare for DNS-01 CF_DNS_API_TOKEN — Secret. Scoped API Token CLOUDFLARE_ZONE — Target zone Use a scoped token with Zone:DNS:Edit on the one zone. Never the Global APIKey, which authenticates everything in the account and cannot be scoped. Secrets .env is git-ignored, bootstrap writes it 0600, inspect prints <set>rather than values, and lint fails on tracked auth keys or private keys. Gatewaystate, including ACME material, lives under state/, which is also ignored. Host metrics portta host collect writes state/metrics/current.json on the host. Thepanel only reads that file — it cannot see the real machine from inside itscontainer, and it never calls systeminformation. portta up andportta web up start a detached watcher that refreshes the snapshot everyfive seconds. See Host metrics. There is no operator setting for collection. The panel'sPORTTA_RUNTIME_METRICS_DIR is the mount path inside the container(default /app/state/metrics). Host tool readiness follows the same boundary. The CLI writesstate/environment/report.json; the panel mounts it read-only at/app/state/environment. See Host environment readiness. Configuration ownership The installation .env is the source of shared configuration and credentials..env.example defines its structure, groups, comments and supported variables.portta config prepare creates or reconciles it without starting services;bootstrap, dev, up, web up, setup and the installer also prepare it. Persisted .env values win over the inherited shell environment. Explicitconfiguration commands write the file before resolving Compose. Runtime selectorssuch as PORTTA_ROOT and PATH are process inputs, notinstallation settings. Internal service ports and filesystem paths in containersare architectural constants. PORTTA_VERSION derives from the generatedruntime version packaged with the installed CLI. Preparation fills absent keys from the template and generates absent/empty secretsonce. It keeps configured values, including deliberate empty optional fields.Missing keys are inserted near their template neighbours. Ordinary editsonly replace the requested values, preserving comments, order, spacing and lineendings. Duplicate keys are rejected. Dotenv content is parsed, never executed;Portta treats values literally rather than evaluating ${OTHER_VARIABLE}. CLI and panel share portta-core's document editor. A .env-lock/writer directory serializeswrites across the host and panel; .env-lock is a shared mount, not image content.Backups and in-place writes preserve the file's inode and mode 0600. A stalelock fails with a diagnostic; only remove it after verifying no writer is active. How those values relate — project hostnames, public access, the panel URL,Traefik, TLS, VPN and DNS — is Addresses and access.The Settings pages edit the same keys without asking you to think in variablenames.","headings":[{"id":"configuration-reference","text":"Configuration reference","level":1,"line":0},{"id":"common","text":"Common","level":2,"line":4},{"id":"local-profile","text":"Local profile","level":2,"line":19},{"id":"header-aliasing","text":"Header aliasing","level":2,"line":35},{"id":"dashboard","text":"Dashboard","level":2,"line":51},{"id":"databases-by-hostname","text":"Databases by hostname","level":2,"line":65},{"id":"web-panel","text":"Web panel","level":2,"line":79},{"id":"tls","text":"TLS","level":2,"line":125},{"id":"private-access","text":"Private access","level":2,"line":144},{"id":"public-access","text":"Public access","level":2,"line":154},{"id":"cloudflare","text":"Cloudflare","level":2,"line":164},{"id":"secrets","text":"Secrets","level":2,"line":175},{"id":"host-metrics","text":"Host metrics","level":2,"line":181},{"id":"configuration-ownership","text":"Configuration ownership","level":2,"line":197}],"kind":"markdown"},{"slug":"installation-reference","title":"Installation reference","description":"Look up unattended installation and version-selection options.","source":"docs/product/reference/installation-reference.md","audience":"user","section":"Reference","category":"","url":"/docs/installation-reference","markdown":"# Installation reference\n\nThe installer checks Node and npm, then invokes `portta setup` from the npm\nrelease:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/fabioassuncao/portta/main/install.sh | sh\n```\n\nSet `PORTTA_VERSION` to select a published npm version. Without it, the current\npublished release is used.\n\n## Setup flags\n\n| Flag | Meaning |\n|---|---|\n| `--dir <path>` | Installation directory; defaults to an existing installation or `~/portta` |\n| `--profile <name>` | `local`, `remote-private` or `remote-public` |\n| `--dry-run` | Validate prerequisites and print the plan without changing files or Docker |\n| `--skip-pull` | Use images already present on the host |\n| `--yes` | Confirm setup non-interactively |\n| `--json` | Emit the setup result as JSON |\n\nSetup requires a POSIX host, Node 22.12+, npm, Docker Engine 24+ and Compose v2.\nIt never installs system packages, invokes `sudo`, edits the firewall or writes\noutside the selected installation.\n\nThe npm package contains the Compose files, image contexts, templates,\nconfiguration defaults, version and runner entrypoint. Setup copies those\nassets, creates `.env` only when absent, preserves gateway state and current\ndynamic configuration, creates gateway-owned directories and networks, pulls\npinned images and starts the selected profile.\n\nAn unrelated non-empty directory is refused. Repeating setup on an installation\nupdates runtime assets while preserving `.env`, state and operator-owned files.\n","text":"Installation reference The installer checks Node and npm, then invokes portta setup from the npmrelease: Set PORTTA_VERSION to select a published npm version. Without it, the currentpublished release is used. Setup flags Flag Meaning --dir <path> Installation directory; defaults to an existing installation or ~/portta --profile <name> local, remote-private or remote-public --dry-run Validate prerequisites and print the plan without changing files or Docker --skip-pull Use images already present on the host --yes Confirm setup non-interactively --json Emit the setup result as JSON Setup requires a POSIX host, Node 22.12+, npm, Docker Engine 24+ and Compose v2.It never installs system packages, invokes sudo, edits the firewall or writesoutside the selected installation. The npm package contains the Compose files, image contexts, templates,configuration defaults, version and runner entrypoint. Setup copies thoseassets, creates .env only when absent, preserves gateway state and currentdynamic configuration, creates gateway-owned directories and networks, pullspinned images and starts the selected profile. An unrelated non-empty directory is refused. Repeating setup on an installationupdates runtime assets while preserving .env, state and operator-owned files.","headings":[{"id":"installation-reference","text":"Installation reference","level":1,"line":0},{"id":"setup-flags","text":"Setup flags","level":2,"line":12}],"kind":"markdown"},{"slug":"cli","title":"CLI reference","description":"Look up commands, flags, streams, exit codes and JSON output.","source":"docs/product/reference/cli.md","audience":"user","section":"Reference","category":"","url":"/docs/cli","markdown":"# CLI reference\n\n`portta` is the installable, machine-first interface to\nthe gateway. It exposes the `portta` binary and requires Node 22.12 or\nnewer. The repository entry point, `./bin/portta`, builds it when needed and\nthen delegates to the same implementation.\n\n## Installation\n\n```bash\nnpx portta --version\nnpm install --global portta\nportta setup --dry-run\nportta setup --yes\n```\n\n`setup` requires POSIX, Node 22.12+, network access, Docker Engine\n24+ and Compose v2. It never installs system packages, invokes `sudo`, edits a\nfirewall or `/etc/hosts`, or overwrites an unrelated directory. It installs the\nruntime assets carried by the npm package, creates `.env` only when absent, ensures\ngateway-owned directories and the shared network, pulls pinned images, starts\nthe selected profile and checks that components stayed running. Repeating it\nis idempotent; `--dry-run` changes nothing.\n\n## Global flags and streams\n\n| Flag | Contract |\n|---|---|\n| `--json` | Emit the documented data object on stdout. Progress and warnings stay on stderr. |\n| `-y`, `--yes` | Confirm every gated operation non-interactively. |\n| `--quiet` | Suppress progress and the elapsed-time line; never suppress errors or requested data. |\n| `--verbose` | Add diagnostic detail on stderr, and stream every child process's output. |\n| `--profile <name>` | Select `local`, `remote-private` or `remote-public`. |\n| `-h`, `--help` | Available at the root and every command level. |\n| `-V`, `--version` | Available globally, including after a subcommand. |\n\n### Progress and long operations\n\nThe CLI runs `docker`, `docker compose` and `git`. What happens to their output\nis a contract, not an accident ([ADR 0034](../../development/adr/0034-child-process-output.md)):\n\n- **Nothing is silent for long.** A child whose output is being captured\n announces itself on stderr after ten seconds and every thirty after that:\n `wait still running: docker compose run --build … (1m20s)`. After three\n minutes it says what to do about it.\n- **Work you are waiting on shows its own output.** Builds, pulls and the first\n start of the panel database stream while they run.\n- **Child stderr is always mirrored; child stdout is mirrored except under\n `--json`.** `docker pull` writes layer progress to stdout, and that must not\n land inside the document a machine is reading. Build progress is on stderr,\n so a `--json` run still sees it.\n- **`--verbose` streams everything**, including the short probes.\n- **A build is never killed on a timer.** A cold first build legitimately takes\n minutes; the elapsed-time line is there so you can decide. `Ctrl-C` during a\n build is safe — BuildKit keeps its cache.\n\nA command that needs confirmation never prompts when stdin is not a TTY. It\nexits 4 and names `--yes` instead. Programs are always executed as an\nexecutable plus an argument array with shell expansion disabled.\n\n### Interaction and shell completion\n\nThe CLI intentionally keeps its prompts small and dependency-free. Confirmation\nis No by default, writes to stderr and is replaced by `--yes` in automation.\nPortta does not currently advertise generated shell completion: the evaluated\nCommander adapter omitted supported aliases and some optional values. See\n[ADR 0041](../../development/adr/0041-cli-interaction-dependencies.md) for the\nevaluation and the compatibility gates a future completion implementation must\npass.\n\n## Exit codes\n\n| Code | Meaning |\n|---:|---|\n| 0 | Success. |\n| 1 | The requested operation failed. |\n| 2 | Usage error: unknown command, missing argument or invalid flag. |\n| 3 | Precondition missing: Docker unavailable, installation absent or gateway down. |\n| 4 | Refused by a safety rule or because confirmation was not supplied. |\n\n## Command tree\n\n### Gateway\n\n| Command | Command-specific flags |\n|---|---|\n| `setup` | `--dir`, `--profile`, `--dry-run`, `--skip-pull` |\n| `bootstrap` | `--skip-pull` |\n| `up [profile]` | `--attach`, `--demo`. `--demo` starts `PORTTA_PROJECTS_HOME/portta-demo-*` and imports their panel records. |\n| `dev [profile]` | `--reset`, `--demo`. Checkout setup from local Dockerfiles; never the published images. Imports example manifests on every run; `--demo` also starts their stacks. `--reset` stops every Portta-managed stack and drops its volumes first. Sign in as `admin@admin.com` / `secret`. |\n| `down` | `--demo`. Without it, consumer projects keep running. With it, `PORTTA_PROJECTS_HOME/portta-demo-*` are stopped and their volumes dropped, then the gateway. |\n| `restart`, `status`, `doctor`, `inspect`, `update`, `version` | Global flags only |\n| `reset` | `--demo`. Alias for `dev --reset`. Stops Portta-managed stacks (gateway, projects on the shared network, example stacks) and drops their volumes; `--demo` then recreates the example stacks and imports their panel records. |\n| `logs [service]` | `--no-follow`, `--tail <lines>` |\n| `urls` | `--project <name>` |\n\n### Projects, environments and work\n\nTwo nouns. A **Project** is the product being developed: a decision the panel\npersists, so these verbs call the panel API. An **environment** is a Compose\nproject Docker is running: an observation, read locally.\n\n| Command | Command-specific flags |\n|---|---|\n| `projects list` | Global flags only |\n| `projects show <slug>` | Repositories with git state, adopted environments |\n| `projects create` | `--slug`, `--name`, `--description`, `--path <dir>` (first-level directory under Projects Home) |\n| `projects context <slug>` | `--task <ref>`. The Development Context an agent reads before working; `--json` carries the instruction files in full |\n| `projects resources <slug>` | Usage attributed through the adopted environments |\n| `projects activity <slug>` | `--kind <a,b>`, `--limit <n>` |\n| `overview` | The Development Dashboard |\n| `envs list` (`env`, `environment` and `project` are aliases) | Global flags only |\n| `envs show <name>` | Global flags only |\n| `envs start\\|stop\\|restart <name>` | Dependency order; nothing is removed. |\n| `envs logs <name>` | `--service <name>`, `--tail <lines>` |\n| `envs endpoints <name>` | The routed hostnames |\n| `envs services` | `--project <name>` |\n| `envs analyze <path>` | Read-only. `--file <path>` names the Compose file (relative to `<path>` or absolute) when it is not `compose.yaml` in `<path>`; the project directory is then the file's. |\n| `envs init <path>` | `--dry-run`, repeatable `--service <name:port>`, `--file`, `--project <slug>`, `--output`, `--force`; writing needs confirmation. With `--file` the overlay is written next to that Compose file. `--project` emits `portta.project` on routed services so worktree namespaces adopt the logical Project. |\n| `envs namespace` | `--path`, `--base`, `--suffix`, `--no-check` |\n| `adopt <path>` | Canonical isolated Compose adoption. `--dry-run --json` reports the Runtime Plan and pending decisions without writing state. Repeat `--service <name:port>` to choose an HTTP surface; `--remove-container-name <service>`, `--allow-shared-networks`, and `--allow-shared-volumes` record explicit compatibility decisions. `--manual` records a project-owned integration without Portta mutations. |\n| `prepare [path]`, `init [path]` | Backward-compatible Runtime Plan primitives; new integrations should use `adopt <path>`. |\n| `runtime up\\|down\\|restart\\|status\\|logs\\|config [path]` | Reuse the validated persisted Runtime Plan; `up` reconciles stale source inputs before starting. |\n| `tasks list` | Optional `--project`; filters for `--status`, `--priority`, `--type`, `--label`, `--assignee`, `--agent`, `--repository`, `--environment`, `--service`, `--parent`, `--open`, `--mine`, `-q` |\n| `tasks next` | `--project <slug>`. The task to take, or nothing |\n| `tasks show\\|view\\|subtasks <ref>` | `<ref>` is an id, `#id`, a globally unique Project key such as `BDH-12`, or `owner/repo#n`; `--json` includes properties, subtasks, comments and binding |\n| `tasks create` | `--project`, `--title`, `--description`, `--priority`, `--status`, `--type`, `--parent`, `--repository`, `--environment`, `--service`, `--labels`, `--assignee`, `--agent`, `--deadline` |\n| `tasks edit\\|update <ref>` | Partial update of title, description and every common property; never overwrites unspecified fields |\n| `tasks start\\|status\\|move\\|block\\|review\\|finish\\|complete\\|reopen <ref>` | All status shortcuts use the same move API; `finish --close` also closes the bound issue |\n| `tasks comment <ref>` | Exactly one of `-m/--message`, `--file` or `--stdin`; creates a local Markdown comment |\n| `tasks subtask list\\|create\\|link` | Read the tree, create a child, or link an existing task |\n| `tasks github status\\|link\\|publish\\|sync` | `link` requires exactly one of `--pull` or `--push`; `sync --resolve local\\|remote` settles a conflict |\n| `sessions start\\|end\\|heartbeat\\|list` | `start --project --task --repository --environment --summary`; `end --summary --abandon` |\n| `activity` | `--project`, `--kind`, `--task`, `--repository`, `--environment`, `--limit` |\n| `examples apply` | `--file`. Low-level re-import of `PORTTA_PROJECTS_HOME/portta-demo-*/portta.example.json`. `--demo` on `up`/`dev`/`reset` also starts the example stacks. |\n\nEvery verb that calls the panel accepts `--url`, `--allow-remote` and\n`--actor` (`PORTTA_ACTOR`), exactly as `portta mcp` does.\n\n`PORTTA_URL` is the API base variable. `PORTTA_TOKEN` sends a Bearer token.\nThe singular `portta task` is an alias of `tasks`.\n\n### Private access\n\n| Command | Command-specific flags |\n|---|---|\n| `access open` | Required `--project`, `--service`; optional `--port`, `--local-port`, `--ttl`, `--network`, `--bind` |\n| `access list` | Global flags only. |\n| `access close [id]` | Alternatively `--project` or `--all`. |\n| `access inspect <id>`, `access gc` | Global flags only. |\n| `service publish` | Required `--private`, `--project`, `--service`; optional `--port`, `--alias`. `--public` is always refused. |\n| `service list` | Global flags only. |\n| `service unpublish [alias]` | Alternatively `--project`. |\n\n`db open|close|url|psql|mysql` and `redis open|close|cli` are typed\nconveniences over the same bridges or one-shot toolbox clients. Client\ncommands require `--project`, accept `--service` and `--port`, and pass trailing\narguments directly to the selected client. `db status|migrate|shell|dump|restore`\noperate on the panel's private PostgreSQL. `migrate` asks the running panel\nto apply pending SQL and needs no flags. Restore needs `--yes` and accepts a\nfile or stdin.\n\n### Panel, network and integrations\n\n| Command | Command-specific flags |\n|---|---|\n| `web up`, `web dev` | `--expose local|vpn`, `--port`, `--read-only`, `--writable` |\n| `web down|disable|restart|status|open|build` | Global flags only. |\n| `web logs [service]` | `web`, `web-ui`, `web-socket-proxy` or `db`. |\n| `auth bootstrap` | `--name`, `--email`, `--password-stdin`; creates the panel owner, once. The password is only ever read from stdin. |\n| `auth login` | `--token`; omitted, the token is read from the terminal without echoing. Checked against the panel before it is saved. |\n| `auth status` | Global flags only. Says the panel's mode and who this terminal is. |\n| `auth logout` | Global flags only. Forgets the credential; does not revoke the token. |\n| `auth whoami` | Global flags only. Never prints a token. |\n| `auth token list` | `--all` for everybody's; needs `user:list`. |\n| `auth token create` | `--name`, `--human`, `--scopes <a,b>`, `--expires-in-days`; the secret is shown once. |\n| `auth token revoke <id>` | Global flags only. Somebody else's needs `user:update`. |\n| `protect host <host>` | `--user`, `--password-stdin`, `--project`, `--service`; creates or rotates a protected-host record. |\n| `protect status [host]` | Read-only; never returns credential hashes. |\n| `protect remove <host>` | Removes the record; the consumer project's middleware label is unchanged. |\n| `auth reset-password <email>` | `--password-stdin`; otherwise a password is generated and shown once. Runs inside the panel container and ends every session of that account. |\n| `users list` | Global flags only. |\n| `users create` | `--name`, `--email`, `--role`, `--projects`, `--password-stdin`; a generated password is shown once. |\n| `users set-role <email> <role>` | Global flags only. The email is resolved to an id through the panel. |\n| `users set-password <email>` | `--password-stdin`; ends every session of that account. |\n| `users grant <email> <project>` | Global flags only. Sends the whole list, with this Project added. |\n| `users revoke <email> <project>` | Global flags only. Sends the whole list, with this Project removed. |\n| `users remove <email>` | Global flags only. |\n| `auth token list\\|create\\|revoke` | Personal Bearer tokens. `create --name [--human] [--scopes <a,b>] [--expires-in-days <n>]` prints the secret once; `list --all` needs `user:list`. |\n| `network status` | `--public-ip` explicitly permits one external lookup. |\n| `public status|enable|disable` | Enable needs confirmation; TCP services are never published. |\n| `dns check|status` | Read-only. |\n| `dns setup` | `--target <ip>`, `--dry-run`; Cloudflare needs a scoped token. |\n| `repos scan` | `--environment <name>`, `--path <dir>`, `--with-prs`, `--forge-ttl <seconds>`. Collects every repository (git state, the last twenty commits, the instruction files on the allowlist) into `state/git/<key>.json` plus `state/git/index.json`, which maps each environment to the repository it runs from. The metrics watcher runs it once a minute. |\n| `repos status`, `repos clear` | Inspect or remove only `state/git/*.json`. |\n| `host collect` | Write host/project metrics and an environment readiness report. |\n| `env report` | Refresh `state/environment/report.json` without changing the host. |\n| `host watch` | Start the detached collector, or run it in the foreground with `--loop`. |\n| `host status` | Whether the collector is running, and how old the last snapshot is. |\n| `share list`, `share revoke <id>`, `share gc` | Shares can only be created in the panel. |\n| `tls status|init` | `init` runs OpenSSL in the toolbox container and enables TLS in `.env`. |\n| `tls trust|untrust` | Print the privileged command for this operating system; never run it. |\n| `remote bootstrap <target>` | `--profile`, `--dir`, `--repo`, `--branch`, `--install-docker`, `--dry-run`. Never copies a secret, never overwrites a remote `.env`. |\n| `remote status|doctor|urls <target>` | Read-only, over SSH. `--json` is forwarded. |\n| `remote exec <target> -- <cmd>` | Runs the command there with the terminal attached. |\n| `remote access open <target>` | `--project`, `--service`, `--port`, `--local-port`, `--dir`. Leaves an SSH tunnel running after the command exits. |\n| `remote access list|close` | `close` takes an id or `--all`; the remote bridge is left for the remote host to close. |\n| `toolbox build` | Build or verify the pinned operational image. |\n| `toolbox run <command...>` | Run an explicit command in the one-shot operational container. |\n| `mcp` | `--url`, `--allow-remote`, `--actor`. Serves the task verbs to an agent over stdio; refuses a non-loopback panel URL without the flag, because that is where a credential would be sent. See [MCP](mcp.md). |\n\nHost key verification is never relaxed: `StrictHostKeyChecking` defaults to\n`accept-new`, which records a key the first time and still refuses a *changed*\none. `PORTTA_SSH_HOST_KEY_POLICY` can tighten it; nothing in the tree sets it\nto `no`, and the static checks reject that unsafe value.\n\n\n### Maintenance and tunnelling\n\n| Command | Command-specific flags |\n|---|---|\n| `tunnel status|setup|enable|disable|test|logs` | `setup` takes `--zone` and reads the token from `--token-file` or a prompt, never from an argument. |\n| `backup` | `-o <file>`, `--no-database`; the archive holds credentials and is written 0600. |\n| `restore <file>` | `--force`; refuses to overwrite a live installation without it, and always writes a safety copy. |\n| `repair` | `--dry-run`; never deletes data, never touches a volume. |\n\n\n## JSON shapes\n\nEvery read command accepts the global `--json`. Stable top-level fields are:\n\n| Command | Top-level data |\n|---|---|\n| `status` | `version`, `instance`, `profile`, `domain`, `bindAddress`, `network`, `components`, `projectCount`, `routeCount`, `tls`, `public` |\n| `doctor` | `ok`, `instance`, `checks[]` (`id`, `status`, `message`, optional `fix`) |\n| `urls` | `instance`, `routes[]` (`project`, `service`, `container`, `hostname`, `url`, `port`, `state`) |\n| `inspect` | `profile`, redacted `configuration`, `composeFiles` |\n| `envs list` | `instance`, `projects[]` (`name`, `state`, `serviceCount`, `urls`) |\n| `envs show` | `instance`, `name`, `state`, `services`, `urls` |\n| `envs services` | `instance`, `services[]` |\n| `envs logs` | `lines[]` (`service`, `line`, `stream`) |\n| `envs analyze` | `path`, `compose_file`, `gateway_overlay`, `project`, `domain`, `services`, `findings` |\n| `envs namespace` | `namespace`, `base`, `suffix` |\n| `projects list` | `projects[]` (`ProjectSummary` of the API) |\n| `projects show` | the API's `Project` |\n| `projects context` | the API's `DevelopmentContext` |\n| `projects resources` | the API's `ProjectResources` |\n| `overview` | the API's `DevelopmentOverview` |\n| `tasks list` | `tasks[]` (`TaskSummary`); `tasks show` is a `Task` |\n| `sessions list` | `sessions[]` (`Session`) |\n| `activity` | `events[]` (`ActivityEvent`) |\n| `access list` | `bridges[]` (`id`, `project`, `service`, `target_port`, `local_port`, `kind`, `expires`, `bind`, `network`, `state`) |\n| `service list` | `forwarders[]` |\n| `web status` | `enabled`, `devMode`, `readOnly`, `expose`, `url`, `panel`, `socketProxy` |\n| `network status` | `instance`, `bindAddress`, `publicIp`, `bindings`, `publicBindings` |\n| `public status` | `enabled`, `profile`, `domain`, `bindAddress` |\n| `dns check` | `domain`, `hostname`, `addresses`, `resolves` |\n| `dns status` | `enabled`, `zone`, `domain`, `tokenSet` |\n| `repos status` | `collectedAt`, `home`, `repositories[]` (`key`, `path`, `name`, `remote`, `location`, `relativePath`, `branch`, `dirty`, `environments[]`, `ageSeconds`) |\n| `repos scan` | `index` (the written index) and `repositories[]` (each collected file) |\n| `share list` | `shares[]` |\n| `db status` | `state`, `container`, `network` |\n| `db migrate` | `applied[]`, `migrations[]` |\n| `tls status` | `enabled`, `mode`, `domain`, `certificate`, `authority`, `acme` |\n| `tunnel status` | `state`, `detail`, `hint`, `zone`, `wildcard`, `tunnel`, `connector`, `credential` |\n| `tunnel setup` | `zone`, `tunnel`, `origin`, `routes[]`, `dns` (`type`, `name`, `target`, `proxied`) |\n| `tunnel test` | `host`, `code`, `ok`, `detail`, `hint` |\n| `backup` | `file`, `size`, `paths[]`, `database` |\n| `repair --dry-run` | `dryRun`, `changes[]` |\n| `remote access list` | `tunnels[]` (`id`, `pid`, `target`, `project`, `service`, `remotePort`, `localPort`, `started`, `address`) |\n\nFields are additive within `0.x`; incompatible changes are called out in the\nchangelog. Secret values never appear in JSON.\n\n## Documentation\n\nThe CLI includes the documentation compiled for its version. These commands need neither a running panel nor internet access:\n\n```bash\nportta docs list\nportta docs search \"custom domain\"\nportta docs show addresses-and-access --anchor dns\nportta docs search \"schema\" --audience developer --limit 5 --json\n```\n\n`list` and `search` accept `--audience user|developer|all` (default `all`). Search accepts `--limit` from 1 to 50 (default 10). `show` accepts a stable slug and optional heading anchor; it includes nested headings up to the next sibling section. Missing documents and anchors are errors.\n\nTo read the version installed on a particular panel:\n\n```bash\nportta docs search \"TLS\" --url http://127.0.0.1:8081\n```\n\n`--url` explicitly selects the panel. Remote non-loopback URLs also require `--allow-remote`; existing Portta token/login configuration applies. Failure does not fall back to local content. JSON output includes `origin`, corpus version, revision when available and a content hash.\n\nThe source is Markdown shared with `/docs`, the documentation API and the MCP tools. See [Use the Portta API](../guides/use-api.md) and [MCP reference](mcp.md).\n","text":"CLI reference portta is the installable, machine-first interface tothe gateway. It exposes the portta binary and requires Node 22.12 ornewer. The repository entry point, ./bin/portta, builds it when needed andthen delegates to the same implementation. Installation setup requires POSIX, Node 22.12+, network access, Docker Engine24+ and Compose v2. It never installs system packages, invokes sudo, edits afirewall or /etc/hosts, or overwrites an unrelated directory. It installs theruntime assets carried by the npm package, creates .env only when absent, ensuresgateway-owned directories and the shared network, pulls pinned images, startsthe selected profile and checks that components stayed running. Repeating itis idempotent; --dry-run changes nothing. Global flags and streams Flag Contract --json Emit the documented data object on stdout. Progress and warnings stay on stderr. -y, --yes Confirm every gated operation non-interactively. --quiet Suppress progress and the elapsed-time line; never suppress errors or requested data. --verbose Add diagnostic detail on stderr, and stream every child process's output. --profile <name> Select local, remote-private or remote-public. -h, --help Available at the root and every command level. -V, --version Available globally, including after a subcommand. Progress and long operations The CLI runs docker, docker compose and git. What happens to their outputis a contract, not an accident (ADR 0034): Nothing is silent for long. A child whose output is being capturedannounces itself on stderr after ten seconds and every thirty after that:wait still running: docker compose run --build … (1m20s). After threeminutes it says what to do about it. Work you are waiting on shows its own output. Builds, pulls and the firststart of the panel database stream while they run. Child stderr is always mirrored; child stdout is mirrored except under--json. docker pull writes layer progress to stdout, and that must notland inside the document a machine is reading. Build progress is on stderr,so a --json run still sees it. --verbose streams everything, including the short probes. A build is never killed on a timer. A cold first build legitimately takesminutes; the elapsed-time line is there so you can decide. Ctrl-C during abuild is safe — BuildKit keeps its cache. A command that needs confirmation never prompts when stdin is not a TTY. Itexits 4 and names --yes instead. Programs are always executed as anexecutable plus an argument array with shell expansion disabled. Interaction and shell completion The CLI intentionally keeps its prompts small and dependency-free. Confirmationis No by default, writes to stderr and is replaced by --yes in automation.Portta does not currently advertise generated shell completion: the evaluatedCommander adapter omitted supported aliases and some optional values. SeeADR 0041 for theevaluation and the compatibility gates a future completion implementation mustpass. Exit codes Code Meaning 0 Success. 1 The requested operation failed. 2 Usage error: unknown command, missing argument or invalid flag. 3 Precondition missing: Docker unavailable, installation absent or gateway down. 4 Refused by a safety rule or because confirmation was not supplied. Command tree Gateway Command Command-specific flags setup --dir, --profile, --dry-run, --skip-pull bootstrap --skip-pull up [profile] --attach, --demo. --demo starts PORTTA_PROJECTS_HOME/portta-demo-* and imports their panel records. dev [profile] --reset, --demo. Checkout setup from local Dockerfiles; never the published images. Imports example manifests on every run; --demo also starts their stacks. --reset stops every Portta-managed stack and drops its volumes first. Sign in as admin@admin.com / secret. down --demo. Without it, consumer projects keep running. With it, PORTTA_PROJECTS_HOME/portta-demo-* are stopped and their volumes dropped, then the gateway. restart, status, doctor, inspect, update, version Global flags only reset --demo. Alias for dev --reset. Stops Portta-managed stacks (gateway, projects on the shared network, example stacks) and drops their volumes; --demo then recreates the example stacks and imports their panel records. logs [service] --no-follow, --tail <lines> urls --project <name> Projects, environments and work Two nouns. A Project is the product being developed: a decision the panelpersists, so these verbs call the panel API. An environment is a Composeproject Docker is running: an observation, read locally. Command Command-specific flags projects list Global flags only projects show <slug> Repositories with git state, adopted environments projects create --slug, --name, --description, --path <dir> (first-level directory under Projects Home) projects context <slug> --task <ref>. The Development Context an agent reads before working; --json carries the instruction files in full projects resources <slug> Usage attributed through the adopted environments projects activity <slug> --kind <a,b>, --limit <n> overview The Development Dashboard envs list (env, environment and project are aliases) Global flags only envs show <name> Global flags only envs start|stop|restart <name> Dependency order; nothing is removed. envs logs <name> --service <name>, --tail <lines> envs endpoints <name> The routed hostnames envs services --project <name> envs analyze <path> Read-only. --file <path> names the Compose file (relative to <path> or absolute) when it is not compose.yaml in <path>; the project directory is then the file's. envs init <path> --dry-run, repeatable --service <name:port>, --file, --project <slug>, --output, --force; writing needs confirmation. With --file the overlay is written next to that Compose file. --project emits portta.project on routed services so worktree namespaces adopt the logical Project. envs namespace --path, --base, --suffix, --no-check adopt <path> Canonical isolated Compose adoption. --dry-run --json reports the Runtime Plan and pending decisions without writing state. Repeat --service <name:port> to choose an HTTP surface; --remove-container-name <service>, --allow-shared-networks, and --allow-shared-volumes record explicit compatibility decisions. --manual records a project-owned integration without Portta mutations. prepare [path], init [path] Backward-compatible Runtime Plan primitives; new integrations should use adopt <path>. runtime up|down|restart|status|logs|config [path] Reuse the validated persisted Runtime Plan; up reconciles stale source inputs before starting. tasks list Optional --project; filters for --status, --priority, --type, --label, --assignee, --agent, --repository, --environment, --service, --parent, --open, --mine, -q tasks next --project <slug>. The task to take, or nothing tasks show|view|subtasks <ref> <ref> is an id, #id, a globally unique Project key such as BDH-12, or owner/repo#n; --json includes properties, subtasks, comments and binding tasks create --project, --title, --description, --priority, --status, --type, --parent, --repository, --environment, --service, --labels, --assignee, --agent, --deadline tasks edit|update <ref> Partial update of title, description and every common property; never overwrites unspecified fields tasks start|status|move|block|review|finish|complete|reopen <ref> All status shortcuts use the same move API; finish --close also closes the bound issue tasks comment <ref> Exactly one of -m/--message, --file or --stdin; creates a local Markdown comment tasks subtask list|create|link Read the tree, create a child, or link an existing task tasks github status|link|publish|sync link requires exactly one of --pull or --push; sync --resolve local|remote settles a conflict sessions start|end|heartbeat|list start --project --task --repository --environment --summary; end --summary --abandon activity --project, --kind, --task, --repository, --environment, --limit examples apply --file. Low-level re-import of PORTTA_PROJECTS_HOME/portta-demo-*/portta.example.json. --demo on up/dev/reset also starts the example stacks. Every verb that calls the panel accepts --url, --allow-remote and--actor (PORTTA_ACTOR), exactly as portta mcp does. PORTTA_URL is the API base variable. PORTTA_TOKEN sends a Bearer token.The singular portta task is an alias of tasks. Private access Command Command-specific flags access open Required --project, --service; optional --port, --local-port, --ttl, --network, --bind access list Global flags only. access close [id] Alternatively --project or --all. access inspect <id>, access gc Global flags only. service publish Required --private, --project, --service; optional --port, --alias. --public is always refused. service list Global flags only. service unpublish [alias] Alternatively --project. db open|close|url|psql|mysql and redis open|close|cli are typedconveniences over the same bridges or one-shot toolbox clients. Clientcommands require --project, accept --service and --port, and pass trailingarguments directly to the selected client. db status|migrate|shell|dump|restoreoperate on the panel's private PostgreSQL. migrate asks the running panelto apply pending SQL and needs no flags. Restore needs --yes and accepts afile or stdin. Panel, network and integrations Command Command-specific flags web up, web dev `--expose local `web down disable web logs [service] web, web-ui, web-socket-proxy or db. auth bootstrap --name, --email, --password-stdin; creates the panel owner, once. The password is only ever read from stdin. auth login --token; omitted, the token is read from the terminal without echoing. Checked against the panel before it is saved. auth status Global flags only. Says the panel's mode and who this terminal is. auth logout Global flags only. Forgets the credential; does not revoke the token. auth whoami Global flags only. Never prints a token. auth token list --all for everybody's; needs user:list. auth token create --name, --human, --scopes <a,b>, --expires-in-days; the secret is shown once. auth token revoke <id> Global flags only. Somebody else's needs user:update. protect host <host> --user, --password-stdin, --project, --service; creates or rotates a protected-host record. protect status [host] Read-only; never returns credential hashes. protect remove <host> Removes the record; the consumer project's middleware label is unchanged. auth reset-password <email> --password-stdin; otherwise a password is generated and shown once. Runs inside the panel container and ends every session of that account. users list Global flags only. users create --name, --email, --role, --projects, --password-stdin; a generated password is shown once. users set-role <email> <role> Global flags only. The email is resolved to an id through the panel. users set-password <email> --password-stdin; ends every session of that account. users grant <email> <project> Global flags only. Sends the whole list, with this Project added. users revoke <email> <project> Global flags only. Sends the whole list, with this Project removed. users remove <email> Global flags only. auth token list|create|revoke Personal Bearer tokens. create --name [--human] [--scopes <a,b>] [--expires-in-days <n>] prints the secret once; list --all needs user:list. network status --public-ip explicitly permits one external lookup. `public status enable `dns check status` dns setup --target <ip>, --dry-run; Cloudflare needs a scoped token. repos scan --environment <name>, --path <dir>, --with-prs, --forge-ttl <seconds>. Collects every repository (git state, the last twenty commits, the instruction files on the allowlist) into state/git/<key>.json plus state/git/index.json, which maps each environment to the repository it runs from. The metrics watcher runs it once a minute. repos status, repos clear Inspect or remove only state/git/*.json. host collect Write host/project metrics and an environment readiness report. env report Refresh state/environment/report.json without changing the host. host watch Start the detached collector, or run it in the foreground with --loop. host status Whether the collector is running, and how old the last snapshot is. share list, share revoke <id>, share gc Shares can only be created in the panel. `tls status init` `tls trust untrust` remote bootstrap <target> --profile, --dir, --repo, --branch, --install-docker, --dry-run. Never copies a secret, never overwrites a remote .env. `remote status doctor remote exec <target> -- <cmd> Runs the command there with the terminal attached. remote access open <target> --project, --service, --port, --local-port, --dir. Leaves an SSH tunnel running after the command exits. `remote access list close` toolbox build Build or verify the pinned operational image. toolbox run <command...> Run an explicit command in the one-shot operational container. mcp --url, --allow-remote, --actor. Serves the task verbs to an agent over stdio; refuses a non-loopback panel URL without the flag, because that is where a credential would be sent. See MCP. Host key verification is never relaxed: StrictHostKeyChecking defaults toaccept-new, which records a key the first time and still refuses a changedone. PORTTA_SSH_HOST_KEY_POLICY can tighten it; nothing in the tree sets itto no, and the static checks reject that unsafe value. Maintenance and tunnelling Command Command-specific flags `tunnel status setup backup -o <file>, --no-database; the archive holds credentials and is written 0600. restore <file> --force; refuses to overwrite a live installation without it, and always writes a safety copy. repair --dry-run; never deletes data, never touches a volume. JSON shapes Every read command accepts the global --json. Stable top-level fields are: Command Top-level data status version, instance, profile, domain, bindAddress, network, components, projectCount, routeCount, tls, public doctor ok, instance, checks[] (id, status, message, optional fix) urls instance, routes[] (project, service, container, hostname, url, port, state) inspect profile, redacted configuration, composeFiles envs list instance, projects[] (name, state, serviceCount, urls) envs show instance, name, state, services, urls envs services instance, services[] envs logs lines[] (service, line, stream) envs analyze path, compose_file, gateway_overlay, project, domain, services, findings envs namespace namespace, base, suffix projects list projects[] (ProjectSummary of the API) projects show the API's Project projects context the API's DevelopmentContext projects resources the API's ProjectResources overview the API's DevelopmentOverview tasks list tasks[] (TaskSummary); tasks show is a Task sessions list sessions[] (Session) activity events[] (ActivityEvent) access list bridges[] (id, project, service, target_port, local_port, kind, expires, bind, network, state) service list forwarders[] web status enabled, devMode, readOnly, expose, url, panel, socketProxy network status instance, bindAddress, publicIp, bindings, publicBindings public status enabled, profile, domain, bindAddress dns check domain, hostname, addresses, resolves dns status enabled, zone, domain, tokenSet repos status collectedAt, home, repositories[] (key, path, name, remote, location, relativePath, branch, dirty, environments[], ageSeconds) repos scan index (the written index) and repositories[] (each collected file) share list shares[] db status state, container, network db migrate applied[], migrations[] tls status enabled, mode, domain, certificate, authority, acme tunnel status state, detail, hint, zone, wildcard, tunnel, connector, credential tunnel setup zone, tunnel, origin, routes[], dns (type, name, target, proxied) tunnel test host, code, ok, detail, hint backup file, size, paths[], database repair --dry-run dryRun, changes[] remote access list tunnels[] (id, pid, target, project, service, remotePort, localPort, started, address) Fields are additive within 0.x; incompatible changes are called out in thechangelog. Secret values never appear in JSON. Documentation The CLI includes the documentation compiled for its version. These commands need neither a running panel nor internet access: list and search accept --audience user|developer|all (default all). Search accepts --limit from 1 to 50 (default 10). show accepts a stable slug and optional heading anchor; it includes nested headings up to the next sibling section. Missing documents and anchors are errors. To read the version installed on a particular panel: --url explicitly selects the panel. Remote non-loopback URLs also require --allow-remote; existing Portta token/login configuration applies. Failure does not fall back to local content. JSON output includes origin, corpus version, revision when available and a content hash. The source is Markdown shared with /docs, the documentation API and the MCP tools. See Use the Portta API and MCP reference.","headings":[{"id":"cli-reference","text":"CLI reference","level":1,"line":0},{"id":"installation","text":"Installation","level":2,"line":7},{"id":"global-flags-and-streams","text":"Global flags and streams","level":2,"line":24},{"id":"progress-and-long-operations","text":"Progress and long operations","level":3,"line":36},{"id":"interaction-and-shell-completion","text":"Interaction and shell completion","level":3,"line":60},{"id":"exit-codes","text":"Exit codes","level":2,"line":70},{"id":"command-tree","text":"Command tree","level":2,"line":80},{"id":"gateway","text":"Gateway","level":3,"line":82},{"id":"projects-environments-and-work","text":"Projects, environments and work","level":3,"line":96},{"id":"private-access","text":"Private access","level":3,"line":142},{"id":"panel-network-and-integrations","text":"Panel, network and integrations","level":3,"line":162},{"id":"maintenance-and-tunnelling","text":"Maintenance and tunnelling","level":3,"line":217},{"id":"json-shapes","text":"JSON shapes","level":2,"line":227},{"id":"documentation","text":"Documentation","level":2,"line":274}],"kind":"markdown"},{"slug":"api","title":"API reference","description":"Explore the OpenAPI contract and try requests against this panel.","source":"packages/contracts/openapi.json","audience":"user","section":"Reference","category":"","url":"/docs/api","markdown":"# API reference\n\nThe OpenAPI contract of this panel is available at /api/openapi.json.\n","text":"API reference The OpenAPI contract of this panel is available at /api/openapi.json.","headings":[{"id":"api-reference","text":"API reference","level":1,"line":0}],"kind":"api"},{"slug":"mcp","title":"MCP reference","description":"Connect agents over stdio and consult the tool contract.","source":"docs/product/reference/mcp.md","audience":"user","section":"Reference","category":"","url":"/docs/mcp","markdown":"# MCP reference\n\n`portta mcp` is a [Model Context Protocol](https://modelcontextprotocol.io)\nserver. It speaks stdio to an agent. Operational tools call the panel API; documentation tools read the bundled corpus unless a panel source is explicitly selected.\n\nThe point of it is what the agent *does not* get: **no GitHub credential and\nno Docker socket**. The App's private key stays a file the panel mounts\nread-only, installation tokens live for an hour in the panel's memory, the\ncontainer lifecycle stays behind the panel's allowlist, and the agent holds\nstdio to a process that knows a panel URL.\n\n```text\nAgent ──stdio──> portta mcp ──HTTP──> Portta panel ──App auth──> GitHub\n │\n Projects, tasks, sessions, activity\n Docker · Git scan · metrics\n```\n\n## Configure it\n\nOperational tools need a running panel with its database (`portta web up`). Documentation tools work locally without a running panel.\nGitHub is optional: without the App, everything below works except the\nverbs that reach github.com.\n\n```jsonc\n{\n \"mcpServers\": {\n \"portta\": {\n \"command\": \"portta\",\n \"args\": [\"mcp\", \"--actor\", \"claude-code\"],\n \"env\": {\n // Only when the panel is authenticated. Omitted for a loopback panel\n // with no credential, which is the default.\n \"PORTTA_TOKEN\": \"ptt_…\"\n }\n }\n }\n}\n```\n\nFor Claude Code, the same thing in one command:\n\n```bash\nclaude mcp add portta -- portta mcp --actor claude-code\n```\n\n| Flag / variable | What it does |\n|---|---|\n| `--url <url>`, `PORTTA_URL` | The panel API base. Defaults to `http://127.0.0.1:<PORTTA_WEB_PORT>` |\n| `--allow-remote` | Permit a non-loopback panel URL. **Required** for one: that URL is where the panel credential would be sent |\n| `--actor <name>`, `PORTTA_MCP_ACTOR` | Sent on every call as `X-Portta-Actor`. Recorded on tasks, notes, sessions and activity; never forwarded to GitHub |\n| `PORTTA_TOKEN` | The Bearer credential a protected panel needs. The token names its owner, and what it holds is the intersection of its scopes and their role. Without it, whatever `portta auth login` saved for this panel is used |\n\n`portta mcp` refuses a non-loopback panel URL unless you pass `--allow-remote`,\nbecause that URL is where a credential goes.\n\n## What the actor means\n\n`X-Portta-Actor` is self-declared. It does not authenticate anything — a token\nor a session does that — it says *which* caller this is. Two things follow:\n\n- every task, note, session and activity event carries the name, so a person\n reading the panel from elsewhere can tell what an agent did;\n- on a panel with `PORTTA_AUTH_MODE=disabled`, an agent that announces itself\n holds the `agentPermissions` setting rather than everything. By default that\n is a developer minus the three things that change how the panel behaves:\n `environment:settings`, `repository:manage` and `github:sync`. A refused call\n answers `403` with the permission named. On a protected panel the token\n decides instead, and the header is attribution alone. See\n [ADR 0032](../../development/adr/0032-portta-development-model.md) and\n [ADR 0035](../../development/adr/0035-authentication-lives-in-the-panel.md).\n\n## The tools\n\nReads, all local to the panel:\n\n| Tool | Reaches |\n|---|---|\n| `list_projects` | `GET /api/projects` |\n| `get_project` | `GET /api/projects/:slug` |\n| `get_context` | `GET /api/projects/:slug/context` — the Development Context, below |\n| `list_repositories` | `GET /api/projects/:slug/repositories` |\n| `get_repository_git` | `GET /api/repositories/:id/git` — branch, HEAD, dirty counts, recent commits, instruction files |\n| `list_environments` | `GET /api/environments` |\n| `get_environment` | `GET /api/environments/:name` |\n| `list_services` | `GET /api/environments/:name/services` — one row per service with its access, resources and actions |\n| `get_logs` | `GET /api/environments/:name/logs` |\n| `get_resources` | `GET /api/metrics/current` |\n| `list_activity` | `GET /api/activity` |\n\nWork:\n\n| Tool | Reaches | Network |\n|---|---|---|\n| `list_tasks` | `GET /api/projects/:slug/tasks` | — |\n| `next_task` | `GET /api/projects/:slug/tasks/next` | — |\n| `get_task` | `GET /api/tasks/:ref` | — |\n| `get_subtasks` | `GET /api/tasks/:ref/subtasks` | — |\n| `create_task` | `POST /api/projects/:slug/tasks` | — |\n| `start_task` | `POST /api/tasks/:ref/start` | GitHub, when the task is bound |\n| `set_task_status` | `POST /api/tasks/:ref/move` | GitHub, when bound |\n| `finish_task` | `POST /api/tasks/:ref/finish` | GitHub, when bound |\n| `update_task` | `PATCH /api/tasks/:ref` | GitHub, for fields in the binding contract |\n| `create_subtask` | `POST /api/tasks/:ref/subtasks` | — |\n| `link_subtask` | `PUT /api/tasks/:ref/subtasks/:childRef` | — |\n| `link_task` | `POST /api/tasks/:ref/github/link` | Requires `initialSync: pull\\|push` |\n| `comment_task` | `POST /api/tasks/:ref/comments` | —; creates a local comment |\n| `start_session` | `POST /api/projects/:slug/sessions` | — |\n| `end_session` | `PATCH /api/sessions/:id` | — |\n\nOperation, each gated by the permission its route declares:\n\n| Tool | Reaches |\n|---|---|\n| `start_environment` | `POST /api/environments/:name/actions/start` |\n| `stop_environment` | `POST /api/environments/:name/actions/stop` |\n| `restart_service` | `POST /api/environments/:name/services/:service/actions/restart` |\n\nOne tool, one endpoint. No tool composes two calls: a workflow that needs\ncomposing composes in the API, where it can be tested without a transport.\n\nA task is addressed by its id (`42`, `#42`) or, when it is bound to a GitHub\nissue, by **`owner/repo#number`** — the coordinate that is already in the\nbranch name, the commit message and the URL.\n\n### The Development Context\n\n`get_context` is what an agent reads before it works. One answer carries:\n\n- the Project: name, description, path;\n- its repositories, each with git state, the git root on the host, the\n environments it runs from, and the **instruction files** the host\n collected (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/*.mdc`, …) with their\n content;\n- the environments it adopted, with their services, primary addresses and\n the commands that start, stop and read their logs;\n- the work: what is in progress and what `next_task` would answer;\n- the effective instructions — the rules of a shared development host, the\n project's note, every repository's files, and the task named with\n `task:` — in the order an agent should read them;\n- the CLI verbs that matter here, ready to copy.\n\n### What `next_task` means\n\nStated once, so it can be argued with:\n\n1. status is `ready` — `backlog` is not triaged, `in_progress` is somebody's;\n2. nothing under it is unfinished (a parent with open subtasks is not work:\n the children are the tasks);\n3. it is unassigned, or assigned to `--actor`;\n4. then by priority, urgent first, unprioritised last;\n5. then by how long it has waited, so a task nobody picks up rises rather\n than starving.\n\nIt answers `null` when there is nothing to do. That is an answer, not an error.\n\n### What a write does\n\n`start_task` sets the status to `in_progress` **and** assigns the actor, in\none write, so a task is never half-taken. `finish_task` sets `done` and, when\nasked, closes the bound issue.\n\nA task is Portta's own. Every write is local first. For a bound task the API\nthen attempts the defined GitHub push; when the App is unavailable the local\nwrite remains successful and the binding is marked `pending` or `error` for\nretry. A remote change that lands on a pending local\nedit is a `conflict`, kept and shown, never resolved silently.\n\n## What an agent cannot do through this\n\n- **Read GitHub comments.** They are never projected. `comment_task` writes a\n local Portta comment; the UI/API can explicitly publish that comment as a\n copy when required.\n- **Destroy anything**, by default: removing an environment or a container\n needs `environment:destroy` or `container:destroy`, which an agent's token\n does not hold unless somebody put it there.\n- **Reach a repository the App was not installed on**, or publish a task to\n a repository its Project does not own.\n- **Hold a GitHub credential, or the Docker socket.**\n\n## When something fails\n\nThe tools carry the panel's answer through as words, because an agent needs to\ntell \"you asked for something impossible\" from \"try again later\":\n\n| The panel said | The tool says |\n|---|---|\n| 400 | `refused: …` — the request will never succeed as written |\n| 401, 403 | `not permitted: …` — no credential, read-only mode, a permission the caller does not hold, a Project they do not reach, or no App configured |\n| 404 | `not found: …` |\n| 503 | `temporarily unavailable, and worth retrying: …` — the database, a GitHub outage or an exhausted rate limit |\n| nothing | the panel URL, and why the connection failed |\n\nRead-only mode (`portta web up --read-only`) refuses every write verb and\nleaves every read working, which makes it a reasonable way to give an agent a\nlook and nothing more.\n\n## Related\n\n- [CLI contract](cli.md) — the same verbs, for a terminal\n- [GitHub](../guides/github.md) — the App, the projection, and how a bound task stays in step\n- [Web UI](../guides/web-ui.md) — the same work, for a person\n- [ADR 0032](../../development/adr/0032-portta-development-model.md) — the model this serves\n\n## Documentation tools\n\n| Tool | Input | Result |\n| --- | --- | --- |\n| `list_docs` | Optional `audience`: `user`, `developer`, `all` | Page metadata and navigation |\n| `search_docs` | `q`, optional `audience` and `limit` (1–50) | Ranked excerpts with document and heading URLs |\n| `get_doc` | `slug`, optional `anchor` | Canonical Markdown or a heading subtree |\n\nAll three tools are read-only. They return structured content and a textual representation with the origin, version and content hash. Internal instructions and research are excluded.\n\nThe default source is the local CLI corpus. Operational tools may still use the panel while documentation remains local. To select that panel's documentation explicitly:\n\n```bash\nportta mcp --docs-source panel --url http://127.0.0.1:8081\n```\n\nRemote access uses the existing `--allow-remote` and authentication contract. An unavailable remote corpus returns an error instead of substituting the local version. No tool fetches documentation from GitHub automatically.\n","text":"MCP reference portta mcp is a Model Context Protocolserver. It speaks stdio to an agent. Operational tools call the panel API; documentation tools read the bundled corpus unless a panel source is explicitly selected. The point of it is what the agent does not get: no GitHub credential andno Docker socket. The App's private key stays a file the panel mountsread-only, installation tokens live for an hour in the panel's memory, thecontainer lifecycle stays behind the panel's allowlist, and the agent holdsstdio to a process that knows a panel URL. Configure it Operational tools need a running panel with its database (portta web up). Documentation tools work locally without a running panel.GitHub is optional: without the App, everything below works except theverbs that reach github.com. For Claude Code, the same thing in one command: Flag / variable What it does --url <url>, PORTTA_URL The panel API base. Defaults to http://127.0.0.1:<PORTTA_WEB_PORT> --allow-remote Permit a non-loopback panel URL. Required for one: that URL is where the panel credential would be sent --actor <name>, PORTTA_MCP_ACTOR Sent on every call as X-Portta-Actor. Recorded on tasks, notes, sessions and activity; never forwarded to GitHub PORTTA_TOKEN The Bearer credential a protected panel needs. The token names its owner, and what it holds is the intersection of its scopes and their role. Without it, whatever portta auth login saved for this panel is used portta mcp refuses a non-loopback panel URL unless you pass --allow-remote,because that URL is where a credential goes. What the actor means X-Portta-Actor is self-declared. It does not authenticate anything — a tokenor a session does that — it says which caller this is. Two things follow: every task, note, session and activity event carries the name, so a personreading the panel from elsewhere can tell what an agent did; on a panel with PORTTA_AUTH_MODE=disabled, an agent that announces itselfholds the agentPermissions setting rather than everything. By default thatis a developer minus the three things that change how the panel behaves:environment:settings, repository:manage and github:sync. A refused callanswers 403 with the permission named. On a protected panel the tokendecides instead, and the header is attribution alone. SeeADR 0032 andADR 0035. The tools Reads, all local to the panel: Tool Reaches list_projects GET /api/projects get_project GET /api/projects/:slug get_context GET /api/projects/:slug/context — the Development Context, below list_repositories GET /api/projects/:slug/repositories get_repository_git GET /api/repositories/:id/git — branch, HEAD, dirty counts, recent commits, instruction files list_environments GET /api/environments get_environment GET /api/environments/:name list_services GET /api/environments/:name/services — one row per service with its access, resources and actions get_logs GET /api/environments/:name/logs get_resources GET /api/metrics/current list_activity GET /api/activity Work: Tool Reaches Network list_tasks GET /api/projects/:slug/tasks — next_task GET /api/projects/:slug/tasks/next — get_task GET /api/tasks/:ref — get_subtasks GET /api/tasks/:ref/subtasks — create_task POST /api/projects/:slug/tasks — start_task POST /api/tasks/:ref/start GitHub, when the task is bound set_task_status POST /api/tasks/:ref/move GitHub, when bound finish_task POST /api/tasks/:ref/finish GitHub, when bound update_task PATCH /api/tasks/:ref GitHub, for fields in the binding contract create_subtask POST /api/tasks/:ref/subtasks — link_subtask PUT /api/tasks/:ref/subtasks/:childRef — link_task POST /api/tasks/:ref/github/link Requires initialSync: pull|push comment_task POST /api/tasks/:ref/comments —; creates a local comment start_session POST /api/projects/:slug/sessions — end_session PATCH /api/sessions/:id — Operation, each gated by the permission its route declares: Tool Reaches start_environment POST /api/environments/:name/actions/start stop_environment POST /api/environments/:name/actions/stop restart_service POST /api/environments/:name/services/:service/actions/restart One tool, one endpoint. No tool composes two calls: a workflow that needscomposing composes in the API, where it can be tested without a transport. A task is addressed by its id (42, #42) or, when it is bound to a GitHubissue, by owner/repo#number — the coordinate that is already in thebranch name, the commit message and the URL. The Development Context get_context is what an agent reads before it works. One answer carries: the Project: name, description, path; its repositories, each with git state, the git root on the host, theenvironments it runs from, and the instruction files the hostcollected (AGENTS.md, CLAUDE.md, .cursor/rules/*.mdc, …) with theircontent; the environments it adopted, with their services, primary addresses andthe commands that start, stop and read their logs; the work: what is in progress and what next_task would answer; the effective instructions — the rules of a shared development host, theproject's note, every repository's files, and the task named withtask: — in the order an agent should read them; the CLI verbs that matter here, ready to copy. What next_task means Stated once, so it can be argued with: status is ready — backlog is not triaged, in_progress is somebody's; nothing under it is unfinished (a parent with open subtasks is not work:the children are the tasks); it is unassigned, or assigned to --actor; then by priority, urgent first, unprioritised last; then by how long it has waited, so a task nobody picks up rises ratherthan starving. It answers null when there is nothing to do. That is an answer, not an error. What a write does start_task sets the status to in_progress and assigns the actor, inone write, so a task is never half-taken. finish_task sets done and, whenasked, closes the bound issue. A task is Portta's own. Every write is local first. For a bound task the APIthen attempts the defined GitHub push; when the App is unavailable the localwrite remains successful and the binding is marked pending or error forretry. A remote change that lands on a pending localedit is a conflict, kept and shown, never resolved silently. What an agent cannot do through this Read GitHub comments. They are never projected. comment_task writes alocal Portta comment; the UI/API can explicitly publish that comment as acopy when required. Destroy anything, by default: removing an environment or a containerneeds environment:destroy or container:destroy, which an agent's tokendoes not hold unless somebody put it there. Reach a repository the App was not installed on, or publish a task toa repository its Project does not own. Hold a GitHub credential, or the Docker socket. When something fails The tools carry the panel's answer through as words, because an agent needs totell \"you asked for something impossible\" from \"try again later\": The panel said The tool says 400 refused: … — the request will never succeed as written 401, 403 not permitted: … — no credential, read-only mode, a permission the caller does not hold, a Project they do not reach, or no App configured 404 not found: … 503 temporarily unavailable, and worth retrying: … — the database, a GitHub outage or an exhausted rate limit nothing the panel URL, and why the connection failed Read-only mode (portta web up --read-only) refuses every write verb andleaves every read working, which makes it a reasonable way to give an agent alook and nothing more. Related CLI contract — the same verbs, for a terminal GitHub — the App, the projection, and how a bound task stays in step Web UI — the same work, for a person ADR 0032 — the model this serves Documentation tools Tool Input Result list_docs Optional audience: user, developer, all Page metadata and navigation search_docs q, optional audience and limit (1–50) Ranked excerpts with document and heading URLs get_doc slug, optional anchor Canonical Markdown or a heading subtree All three tools are read-only. They return structured content and a textual representation with the origin, version and content hash. Internal instructions and research are excluded. The default source is the local CLI corpus. Operational tools may still use the panel while documentation remains local. To select that panel's documentation explicitly: Remote access uses the existing --allow-remote and authentication contract. An unavailable remote corpus returns an error instead of substituting the local version. No tool fetches documentation from GitHub automatically.","headings":[{"id":"mcp-reference","text":"MCP reference","level":1,"line":0},{"id":"configure-it","text":"Configure it","level":2,"line":18},{"id":"what-the-actor-means","text":"What the actor means","level":2,"line":56},{"id":"the-tools","text":"The tools","level":2,"line":72},{"id":"the-development-context","text":"The Development Context","level":3,"line":125},{"id":"what-next_task-means","text":"What next_task means","level":3,"line":142},{"id":"what-a-write-does","text":"What a write does","level":3,"line":156},{"id":"what-an-agent-cannot-do-through-this","text":"What an agent cannot do through this","level":2,"line":168},{"id":"when-something-fails","text":"When something fails","level":2,"line":180},{"id":"related","text":"Related","level":2,"line":197},{"id":"documentation-tools","text":"Documentation tools","level":2,"line":204}],"kind":"markdown"},{"slug":"host-metrics","title":"Host metrics","description":"Inspect host and project resource collection and snapshots.","source":"docs/product/reference/host-metrics.md","audience":"user","section":"Reference","category":"","url":"/docs/host-metrics","markdown":"# Host metrics\n\nThe panel lives in a container. On macOS the chain is Mac → OrbStack or Docker\nDesktop → a Linux VM → the panel. Anything the panel reads from `/proc`, the\nEngine's `GET /info`, or `systeminformation` inside that container is the VM,\nnot the machine.\n\nSo collection runs on the host, the same way [Git does](../../development/adr/0010-git-collected-on-the-host.md).\nThe CLI talks to `systeminformation` and Docker, writes files under\n`state/metrics`, and the panel only reads them. There is no Prometheus, no\nGrafana, no privileged panel, and no extra `/proc` bind.\n\n## What is collected\n\n| Layer | Source | What it is |\n|---|---|---|\n| **Host** | `systeminformation` on the CLI process | The real machine: model, the chassis folded into a `kind` (notebook, desktop, server, vm), the commercial name on macOS (`productName`), OS, cores, RAM, load, the filesystem that holds `$PORTTA_ROOT`, GPU, temperature and battery when the machine has them |\n| **Runtime** | `docker info` `OperatingSystem` | A hint only: OrbStack, Docker Desktop, or the Engine. Never treated as the host |\n| **Projects** | `docker stats` + Compose / `portta.project` labels | CPU and memory rolled up per project |\n| **Containers** | The same `docker stats` | Per-container CPU, memory, network and block I/O |\n\n`systeminformation`'s own Docker helpers talk to the engine socket in a way\nOrbStack does not answer. Host facts stay with the library; Docker facts go\nthrough the same `docker` CLI the rest of the gateway already uses.\n\nA GPU utilisation of `0` without a utilisation field is stored as `null`. Zero\nis only a reading when the source actually reported it.\n\nOn Linux the host and the Engine usually describe the same box. On macOS they\ndo not: the host card must show macOS, Apple silicon and the physical RAM;\nOrbStack is a badge, not the machine.\n\n## Files\n\n```text\nstate/metrics/current.json the latest snapshot (written atomically)\nstate/metrics/history.jsonl one compact point every 15 seconds, kept 60 minutes\nstate/metrics/instance.json a stable UUID for this gateway root\nstate/metrics/collector.pid the detached watcher\nstate/logs/host-metrics.log rotated at 256 KiB\n```\n\nThe panel container mounts `state/metrics` read-only at\n`/app/state/metrics`. A missing or unreadable file is an empty response,\nnever an error.\n\n## Commands\n\n| Command | Purpose |\n|---|---|\n| `portta host collect` | Write one metrics snapshot and one [environment readiness](host-readiness.md) report, then exit |\n| `portta host watch` | Start the detached collector if it is not already running |\n| `portta host watch --loop` | Run in the foreground (what the detached child does) |\n| `portta host status` | Whether it is running, and how old the last snapshot is |\n\n`--json` works on all four. Collection is every 5 seconds; a point older than\n30 seconds is **stale**.\n\nReadiness changes more slowly and is collected every five minutes. Use\n`portta env report` to refresh only that report.\n\n`portta up` and `portta web up` start the watcher. `portta down` and\n`portta web down` stop it. If the gateway stays up after `web down`, the\nOverview goes stale until the next `up`.\n\n## The panel\n\n`GET /api/metrics/current` is the latest snapshot plus `ageSeconds`, `stale`\nand `collectorActive`. `GET /api/metrics/history?window=15m|30m|60m` is the\nshort history. Every snapshot uses the current metrics schema.\n\nThe Overview polls current every 5 seconds and history every 15. The project\npage shows that project's containers from the same snapshot.\n\n## Why it is not in the panel\n\nThe same refusals as Git: the panel mounts no project directory, ships no\nhost inventory library, and must not need `--privileged` to tell the truth\nabout the machine it is sitting on. A collector in the runner would couple\nmetrics to an opt-in that most hosts leave off.\n","text":"Host metrics The panel lives in a container. On macOS the chain is Mac → OrbStack or DockerDesktop → a Linux VM → the panel. Anything the panel reads from /proc, theEngine's GET /info, or systeminformation inside that container is the VM,not the machine. So collection runs on the host, the same way Git does.The CLI talks to systeminformation and Docker, writes files understate/metrics, and the panel only reads them. There is no Prometheus, noGrafana, no privileged panel, and no extra /proc bind. What is collected Layer Source What it is Host systeminformation on the CLI process The real machine: model, the chassis folded into a kind (notebook, desktop, server, vm), the commercial name on macOS (productName), OS, cores, RAM, load, the filesystem that holds $PORTTA_ROOT, GPU, temperature and battery when the machine has them Runtime docker info OperatingSystem A hint only: OrbStack, Docker Desktop, or the Engine. Never treated as the host Projects docker stats + Compose / portta.project labels CPU and memory rolled up per project Containers The same docker stats Per-container CPU, memory, network and block I/O systeminformation's own Docker helpers talk to the engine socket in a wayOrbStack does not answer. Host facts stay with the library; Docker facts gothrough the same docker CLI the rest of the gateway already uses. A GPU utilisation of 0 without a utilisation field is stored as null. Zerois only a reading when the source actually reported it. On Linux the host and the Engine usually describe the same box. On macOS theydo not: the host card must show macOS, Apple silicon and the physical RAM;OrbStack is a badge, not the machine. Files The panel container mounts state/metrics read-only at/app/state/metrics. A missing or unreadable file is an empty response,never an error. Commands Command Purpose portta host collect Write one metrics snapshot and one environment readiness report, then exit portta host watch Start the detached collector if it is not already running portta host watch --loop Run in the foreground (what the detached child does) portta host status Whether it is running, and how old the last snapshot is --json works on all four. Collection is every 5 seconds; a point older than30 seconds is stale. Readiness changes more slowly and is collected every five minutes. Useportta env report to refresh only that report. portta up and portta web up start the watcher. portta down andportta web down stop it. If the gateway stays up after web down, theOverview goes stale until the next up. The panel GET /api/metrics/current is the latest snapshot plus ageSeconds, staleand collectorActive. GET /api/metrics/history?window=15m|30m|60m is theshort history. Every snapshot uses the current metrics schema. The Overview polls current every 5 seconds and history every 15. The projectpage shows that project's containers from the same snapshot. Why it is not in the panel The same refusals as Git: the panel mounts no project directory, ships nohost inventory library, and must not need --privileged to tell the truthabout the machine it is sitting on. A collector in the runner would couplemetrics to an opt-in that most hosts leave off.","headings":[{"id":"host-metrics","text":"Host metrics","level":1,"line":0},{"id":"what-is-collected","text":"What is collected","level":2,"line":12},{"id":"files","text":"Files","level":2,"line":32},{"id":"commands","text":"Commands","level":2,"line":46},{"id":"the-panel","text":"The panel","level":2,"line":65},{"id":"why-it-is-not-in-the-panel","text":"Why it is not in the panel","level":2,"line":74}],"kind":"markdown"},{"slug":"host-readiness","title":"Host environment readiness","description":"Check which host tools are installed and whether they are usable.","source":"docs/product/reference/host-readiness.md","audience":"user","section":"Reference","category":"","url":"/docs/host-readiness","markdown":"# Host environment readiness\n\nThe panel runs in a container, so its `PATH`, SSH client and Docker access do\nnot describe the host. Portta collects readiness on the host and lets the panel\nread the result. The panel never runs these probes itself.\n\n## What is checked\n\nThe report separates **installed** from **usable**. Each tool can carry its\nversion, resolved path and sub-findings that explain the verdict.\n\n| Category | Checks |\n|---|---|\n| Infrastructure | Docker client, tested engine version, daemon/socket access, Compose v2, Tailscale connection |\n| Development | Node.js, npm, npx, Git and its author identity, GitHub CLI and its login, OpenSSH client and agent, tmux |\n| Agents | Claude Code, Codex CLI, Cursor agent, Gemini CLI and Antigravity |\n\nAn absent optional tool is **information**, not a warning. An installed GitHub\nCLI without a login is a recommendation because the workflow exists but cannot\nbe used. Docker or a required Node tool that cannot run is a problem. Every\nprobe has a four-second timeout and invokes an executable with an argument\narray; it never builds a shell command, creates a tmux session, prints a token,\nor stores raw command output.\n\n## Collection and files\n\n```text\nstate/environment/report.json latest readiness report, mode 0600\nstate/environment/security.json read-only host security observations, mode 0600\n```\n\n`portta host collect` writes both resource metrics and readiness. The detached\nhost watcher refreshes readiness every five minutes while keeping resource\nmetrics on their five-second cadence. To refresh only readiness, run:\n\n```bash\nportta env report\n```\n\nThe command is also available through the canonical `portta envs report`\nspelling. The write is atomic. The panel mounts `state/environment` read-only.\n\nThe same refresh collects host security observations. SSH server policy,\ninstalled firewalls and Fail2ban are graded using the host kind and exposure\nprofile. A permission error remains “could not be checked”; it is never\nrewritten as “not installed”. None of these probes uses `sudo` or a mutating\ncommand.\n\n## API and panel\n\n`GET /api/environment` requires `metrics:read`. A missing, oversized, malformed\nor old-version file produces a valid “never collected” response, not a server\nerror. The server validates every check as untrusted input and recomputes the\nsummary rather than trusting counts from disk.\n\nSettings → Environment shows counts, collection age, tool paths and the\nevidence for each verdict. A report older than 15 minutes is marked stale.\nRefreshing remains a host action: the page prints `portta env report` instead\nof widening the panel's command or Docker permissions.\n\n`GET /api/environment/security` returns the companion Host security group.\nEach row says why the observation matters and links to the relevant guide.\n","text":"Host environment readiness The panel runs in a container, so its PATH, SSH client and Docker access donot describe the host. Portta collects readiness on the host and lets the panelread the result. The panel never runs these probes itself. What is checked The report separates installed from usable. Each tool can carry itsversion, resolved path and sub-findings that explain the verdict. Category Checks Infrastructure Docker client, tested engine version, daemon/socket access, Compose v2, Tailscale connection Development Node.js, npm, npx, Git and its author identity, GitHub CLI and its login, OpenSSH client and agent, tmux Agents Claude Code, Codex CLI, Cursor agent, Gemini CLI and Antigravity An absent optional tool is information, not a warning. An installed GitHubCLI without a login is a recommendation because the workflow exists but cannotbe used. Docker or a required Node tool that cannot run is a problem. Everyprobe has a four-second timeout and invokes an executable with an argumentarray; it never builds a shell command, creates a tmux session, prints a token,or stores raw command output. Collection and files portta host collect writes both resource metrics and readiness. The detachedhost watcher refreshes readiness every five minutes while keeping resourcemetrics on their five-second cadence. To refresh only readiness, run: The command is also available through the canonical portta envs reportspelling. The write is atomic. The panel mounts state/environment read-only. The same refresh collects host security observations. SSH server policy,installed firewalls and Fail2ban are graded using the host kind and exposureprofile. A permission error remains “could not be checked”; it is neverrewritten as “not installed”. None of these probes uses sudo or a mutatingcommand. API and panel GET /api/environment requires metrics:read. A missing, oversized, malformedor old-version file produces a valid “never collected” response, not a servererror. The server validates every check as untrusted input and recomputes thesummary rather than trusting counts from disk. Settings → Environment shows counts, collection age, tool paths and theevidence for each verdict. A report older than 15 minutes is marked stale.Refreshing remains a host action: the page prints portta env report insteadof widening the panel's command or Docker permissions. GET /api/environment/security returns the companion Host security group.Each row says why the observation matters and links to the relevant guide.","headings":[{"id":"host-environment-readiness","text":"Host environment readiness","level":1,"line":0},{"id":"what-is-checked","text":"What is checked","level":2,"line":6},{"id":"collection-and-files","text":"Collection and files","level":2,"line":24},{"id":"api-and-panel","text":"API and panel","level":2,"line":48}],"kind":"markdown"},{"slug":"compatibility","title":"Compatibility reference","description":"Check supported platforms, images and measured overhead.","source":"docs/product/reference/compatibility.md","audience":"user","section":"Reference","category":"","url":"/docs/compatibility","markdown":"# Compatibility reference\n\n## What is actually tested\n\nClaims here are limited to what is exercised. Nothing is listed as supported\nbecause it \"should\" work.\n\n| Platform | Status |\n|---|---|\n| macOS 15+ (arm64) + OrbStack | **Verified**, full suite run by hand during development |\n| Ubuntu 24.04 (amd64) + Docker Engine | Integration in PR CI; full E2E on release tags |\n| macOS + Docker Desktop | **Expected to work, not verified.** Nothing here uses an OrbStack-specific API. |\n| Debian 12 + Docker Engine | **Expected to work, not verified.** |\n| Linux arm64 | **Expected to work, not verified.** Every pinned image publishes arm64. |\n| Windows / WSL2 | **Untested.** Loopback and `*.localhost` behave differently enough that it needs its own verification. |\n\nMinimum versions: Docker Engine 24, Docker Compose v2. `bootstrap` warns below\nthose, and `doctor` reports the versions it found.\n\nThe CLI targets **bash 3.2**, which is what macOS still ships, so no\nassociative arrays, no `${var,,}`, no `mapfile`. That constraint is why the\nscripts look the way they do.\n\n## Remote profiles\n\nThe `remote-private` and `remote-public` profiles are covered by configuration\ntests: every profile renders, the private profile never binds `0.0.0.0`, and\nthe rendered private profile shares Traefik's network namespace with Tailscale.\n\nThe parts that need real credentials (a tailnet, an ACME account, a DNS zone)\nare **not exercised by any automated test**. `docs/remote-development.md` has a\nsmoke checklist to run by hand after a first deploy, and\n`docs/tailscale-services.md` states which half of that feature is tested.\n\n## Overhead\n\nMeasured on macOS 15 / OrbStack / arm64, with four environments and eight\nrouted services running.\n\n| | Memory | Notes |\n|---|---|---|\n| Traefik | ~48 MiB | the only permanently running router |\n| Docker socket proxy | ~25 MiB | HAProxy, read-only |\n| **Permanent total** | **~73 MiB** | for the whole machine, not per project |\n| Access bridge | ~1.4 MiB | one per open session, removed on close |\n| Toolbox client | ~0 | exists only while the command runs |\n\n| | Time |\n|---|---|\n| `bootstrap` (idempotent, no pull) | ~4 s |\n| `doctor` (full diagnostics) | ~3 s |\n| Traefik discovering a new route | under 1 s |\n\nTwo containers is the entire standing footprint. There is deliberately nothing\nelse: bridges are created per session and removed, clients are one-shot, and\nthe toolbox image is built once and then only run on demand.\n\nFor comparison, the thing this replaces, a published host port per service,\ncosts no memory but costs a port, which is the resource that actually runs out.\n\n## Images\n\nEvery version is pinned; see\n[ADR 0004](../../development/adr/0004-pinned-versions.md) for the table and the update process.\n\nAll pinned images publish both `amd64` and `arm64`, so the same configuration\nruns on an Apple Silicon Mac and an x86 VPS without changes.\n\n## Reporting a platform\n\nIf you run this somewhere not listed, the useful report is:\n\n```bash\nuname -s -m\ndocker version --format '{{.Server.Version}}'\ndocker compose version --short\nbash --version | head -1\nnpm run test:release\n```\n","text":"Compatibility reference What is actually tested Claims here are limited to what is exercised. Nothing is listed as supportedbecause it \"should\" work. Platform Status macOS 15+ (arm64) + OrbStack Verified, full suite run by hand during development Ubuntu 24.04 (amd64) + Docker Engine Integration in PR CI; full E2E on release tags macOS + Docker Desktop Expected to work, not verified. Nothing here uses an OrbStack-specific API. Debian 12 + Docker Engine Expected to work, not verified. Linux arm64 Expected to work, not verified. Every pinned image publishes arm64. Windows / WSL2 Untested. Loopback and *.localhost behave differently enough that it needs its own verification. Minimum versions: Docker Engine 24, Docker Compose v2. bootstrap warns belowthose, and doctor reports the versions it found. The CLI targets bash 3.2, which is what macOS still ships, so noassociative arrays, no ${var,,}, no mapfile. That constraint is why thescripts look the way they do. Remote profiles The remote-private and remote-public profiles are covered by configurationtests: every profile renders, the private profile never binds 0.0.0.0, andthe rendered private profile shares Traefik's network namespace with Tailscale. The parts that need real credentials (a tailnet, an ACME account, a DNS zone)are not exercised by any automated test. docs/remote-development.md has asmoke checklist to run by hand after a first deploy, anddocs/tailscale-services.md states which half of that feature is tested. Overhead Measured on macOS 15 / OrbStack / arm64, with four environments and eightrouted services running. Memory Notes Traefik ~48 MiB the only permanently running router Docker socket proxy ~25 MiB HAProxy, read-only Permanent total ~73 MiB for the whole machine, not per project Access bridge ~1.4 MiB one per open session, removed on close Toolbox client ~0 exists only while the command runs Time bootstrap (idempotent, no pull) ~4 s doctor (full diagnostics) ~3 s Traefik discovering a new route under 1 s Two containers is the entire standing footprint. There is deliberately nothingelse: bridges are created per session and removed, clients are one-shot, andthe toolbox image is built once and then only run on demand. For comparison, the thing this replaces, a published host port per service,costs no memory but costs a port, which is the resource that actually runs out. Images Every version is pinned; seeADR 0004 for the table and the update process. All pinned images publish both amd64 and arm64, so the same configurationruns on an Apple Silicon Mac and an x86 VPS without changes. Reporting a platform If you run this somewhere not listed, the useful report is:","headings":[{"id":"compatibility-reference","text":"Compatibility reference","level":1,"line":0},{"id":"what-is-actually-tested","text":"What is actually tested","level":2,"line":2},{"id":"remote-profiles","text":"Remote profiles","level":2,"line":23},{"id":"overhead","text":"Overhead","level":2,"line":34},{"id":"images","text":"Images","level":2,"line":60},{"id":"reporting-a-platform","text":"Reporting a platform","level":2,"line":68}],"kind":"markdown"},{"slug":"changelog","title":"Changelog","description":"Released changes and upgrade notes.","source":"CHANGELOG.md","audience":"user","section":"Reference","category":"","url":"/docs/changelog","markdown":"# Changelog\n\nAll notable changes to the current Portta release are documented here. The\nformat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the\nproject uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n### Changed\n\n- The TypeScript `portta` package is now the only CLI implementation. The\n checkout launcher and installer both delegate to it.\n- `portta setup` installs the runtime assets bundled in the npm package and no\n longer requires a Git checkout.\n- The API, MCP server and panel expose only the current task-comment,\n authentication, project and environment contracts.\n- The database starts from one generated migration representing the current\n schema.\n\n### Removed\n\n- Duplicate shell command implementations and command passthroughs.\n- Superseded API routes, MCP tools, contract fields, URL payload shapes and\n authentication formats.\n- Upgrade adapters and runtime reconciliation for retired configuration and\n database layouts.\n","text":"Changelog All notable changes to the current Portta release are documented here. Theformat follows Keep a Changelog and theproject uses Semantic Versioning. [Unreleased] Changed The TypeScript portta package is now the only CLI implementation. Thecheckout launcher and installer both delegate to it. portta setup installs the runtime assets bundled in the npm package and nolonger requires a Git checkout. The API, MCP server and panel expose only the current task-comment,authentication, project and environment contracts. The database starts from one generated migration representing the currentschema. Removed Duplicate shell command implementations and command passthroughs. Superseded API routes, MCP tools, contract fields, URL payload shapes andauthentication formats. Upgrade adapters and runtime reconciliation for retired configuration anddatabase layouts.","headings":[{"id":"changelog","text":"Changelog","level":1,"line":0},{"id":"unreleased","text":"[Unreleased]","level":2,"line":6},{"id":"changed","text":"Changed","level":3,"line":8},{"id":"removed","text":"Removed","level":3,"line":19}],"kind":"markdown"},{"slug":"architecture","title":"Portta architecture","description":"Understand the gateway components and lifecycle boundaries.","source":"docs/product/concepts/architecture.md","audience":"user","section":"Concepts","category":"","url":"/docs/architecture","markdown":"# Portta architecture\n\n## The one idea\n\nA container port and a host port are different things. Ten containers can all\nlisten on 3000 forever. The conflict only appears when something publishes\n3000 *on the host*.\n\nSo the gateway publishes almost nothing. One router holds 80 and 443 for the\nwhole machine, and everything else is reached by hostname over a shared Docker\nnetwork.\n\n## Components\n\n| Component | Image | Role |\n|---|---|---|\n| Traefik | `traefik:v3.7.12` | The only process holding 80/443. Routes by hostname. |\n| Docker socket proxy | `tecnativa/docker-socket-proxy:v0.5.0` | Read-only, filtered Docker API for discovery. |\n| Portta auth | `fabioassuncao/portta:<VERSION>` | ForwardAuth for *project* hostnames and shares: a branded login and host-scoped sessions; no published port. Not the panel's login. |\n| `bin/portta` | — | The operational contract: bootstrap, up/down, doctor, urls, access. |\n| Web panel | `fabioassuncao/portta:<VERSION>` | Optional. One Node process: Next pages, the Hono API, the event stream and the WebSocket upgrades, on one port. |\n| Panel socket proxy | `tecnativa/docker-socket-proxy:v0.5.0` | With the panel. The panel's own filtered Docker API. |\n| Panel PostgreSQL | `postgres:18.6-alpine` | With the panel, and required by it: accounts, decisions and identity, never runtime observations. |\n\nThe enabled Compose features determine the permanent footprint. Bridges and toolbox containers are created on demand and removed\nwhen done.\n\n### The panel is one process\n\nPages, API, events and WebSockets all answer on one port, from one Node\nprocess: a session cookie has one origin, and a panel split across two ports\nwould need a proxy in front of it to have one. A small HTTP server dispatches\n`/api/*` to Hono, `/ws/*` to the authorised upgrade handler and everything else\nto Next's App Router — see\n[ADR 0036](../../development/adr/0036-next-app-router-and-the-custom-server.md).\n\nIt signs people in itself. `PORTTA_AUTH_MODE=disabled` answers everybody as the\nlocal operator and is only allowed on loopback; `required` gives it accounts,\nroles, sessions, `ptt_` tokens and an optional second factor, all in its own\ndatabase ([ADR 0035](../../development/adr/0035-authentication-lives-in-the-panel.md),\n[ADR 0038](../../development/adr/0038-roles-and-project-access.md),\n[ADR 0039](../../development/adr/0039-personal-api-tokens.md)). PostgreSQL is a boot dependency\nrather than a feature: the panel refuses to start without it\n([ADR 0037](../../development/adr/0037-drizzle-and-a-required-database.md)).\n\nFor implementation boundaries and command development, see [Monorepo layout](../../development/monorepo.md).\n\n## Networks\n\n```mermaid\nflowchart LR\n subgraph ctrl [portta-control: internal]\n T[Traefik] <--> P[socket-proxy]\n end\n subgraph shared [portta: external, shared]\n T2[Traefik] --- W1[project-a web] & A1[project-a api] & W2[project-b web]\n end\n subgraph priv_a [project-a_default: private]\n W1b[web] --- DB1[(postgres)]\n A1b[api] --- DB1\n W1b --- R1[(redis)]\n end\n subgraph priv_b [project-b_default: private]\n W2b[web] --- DB2[(postgres)]\n end\n```\n\n**`portta`** is external, created by `bootstrap`, and shared by every\nproject.\nIts lifecycle is independent of both the gateway stack and the projects: it\nsurvives `portta down` and is never removed automatically.\n\n**`portta-control`** is created with `internal: true`, so it has no route\noff the host. Only Traefik and the socket proxy are on it. This is what keeps\nthe Docker API away from anything that handles network traffic.\n\n**`portta-web`** exists only when the panel is enabled. It is also\n`internal: true`, and carries nothing but the panel and its own socket proxy.\nThe two proxies are separate because their permission sets are:\nTraefik's is read-only, the panel's adds the container lifecycle\n([ADR 0008](../../development/adr/0008-web-panel-socket-proxy.md)).\n\n**`portta-data`** also exists only with the panel. It is `internal: true`\nand carries only the panel and PostgreSQL. The database has no published port,\nnever joins `portta`, and keeps data in a named volume. It persists typed\npreferences and stable identity while Docker, Git and Traefik remain the live\nsources of runtime observations. See [Panel persistence](persistence.md).\n\n**`<project>_default`** is each project's own network, created by its own\nCompose file. Postgres, Redis, queues and search live here and nowhere else.\nTraefik has no route to these networks and never needs one.\n\nA service that should be reachable through the gateway joins **both** its\nprivate network and the shared one. Nothing else changes about it.\n\n## How a request is routed\n\n1. `demo-a-web.localhost` resolves to `127.0.0.1` (see\n [Develop applications locally](../guides/local-development.md)).\n2. Traefik, holding `127.0.0.1:80`, matches the `Host` header.\n3. The matching router points at a service Traefik built from the container's\n labels, and dials the container **over the `portta` network**, pinned\n by `providers.docker.network` so a multi-homed container is never reached\n through a private network.\n4. The application answers on its own internal port. Nothing was published.\n\n## How a service is discovered\n\nTraefik's Docker provider watches the event stream through the socket proxy.\n`exposedByDefault=false` means a container is ignored unless it sets\n`traefik.enable=true`.\n\nFor an opted-in container with no explicit rule, the hostname comes from\n`providers.docker.defaultRule`, a template over the labels Compose already\ninjects ([ADR 0005](../../development/adr/0005-hostname-convention.md)):\n\n```text\n<com.docker.compose.project>-<com.docker.compose.service>.<domain>\n```\n\nSo a project never writes its own name into a routing rule, and a new worktree\ngets new hostnames by changing one environment variable.\n\n## Lifecycle independence\n\nThis matters enough to be a design constraint rather than a nice property:\n\n- `portta down` stops **two containers**. Every application keeps running.\n- `portta up` rediscovers whatever is already running.\n- `portta restart` does not restart a single application container.\n- Tearing down a project leaves the gateway healthy and the shared network intact.\n\n`tests/e2e/lifecycle.test.sh` asserts all of it.\n\nThe panel may still **operate** a project on request, without owning it\n([ADR 0030](../../development/adr/0030-the-panel-and-a-project-lifecycle.md)): start, stop and\nrestart by iterating the containers it can already see, and rebuild or take a\nproject down through one opt-in runner (`PORTTA_RUNNER=true`) whose command is\nfixed at creation. `portta down` still stops only the gateway.\n\n## Ownership\n\nEverything the gateway creates carries:\n\n```text\nportta.managed=true\nportta.component=<traefik|socket-proxy|shared-network|access-bridge|...>\n```\n\nEvery path that stops or removes anything checks that label first. There is no\ncode path that can remove a consumer container, network or volume.\n\n## Profiles\n\n| Profile | Reachable from | TLS |\n|---|---|---|\n| `local` | loopback | off by default |\n| `remote-private` | the tailnet only | optional |\n| `remote-public` | the internet, opt-in | ACME wildcard |\n\nProfiles are Compose overlays that add only the keys they change, over a shared\n`docker/compose/compose.yaml` ([ADR 0003](../../development/adr/0003-traefik-static-config-via-env.md)).\n\n## What the gateway deliberately cannot do\n\nIt does not own a project's containers, volumes or release cycle. On request\nit may start, stop or restart what it can see, in Compose dependency order,\nand it may ask Compose to rebuild or take a project down through the opt-in\nrunner ([ADR 0030](../../development/adr/0030-the-panel-and-a-project-lifecycle.md)). Rebuild\npreserves volumes. Removal is two named modes — keep data, or include local\ndata — and the Compose project name is typed back, on the server. Nothing\non GitHub is touched. It cannot repair a misconfigured project. `doctor`\nand `analyze` still only observe.\n","text":"Portta architecture The one idea A container port and a host port are different things. Ten containers can alllisten on 3000 forever. The conflict only appears when something publishes3000 on the host. So the gateway publishes almost nothing. One router holds 80 and 443 for thewhole machine, and everything else is reached by hostname over a shared Dockernetwork. Components Component Image Role Traefik traefik:v3.7.12 The only process holding 80/443. Routes by hostname. Docker socket proxy tecnativa/docker-socket-proxy:v0.5.0 Read-only, filtered Docker API for discovery. Portta auth fabioassuncao/portta:<VERSION> ForwardAuth for project hostnames and shares: a branded login and host-scoped sessions; no published port. Not the panel's login. bin/portta — The operational contract: bootstrap, up/down, doctor, urls, access. Web panel fabioassuncao/portta:<VERSION> Optional. One Node process: Next pages, the Hono API, the event stream and the WebSocket upgrades, on one port. Panel socket proxy tecnativa/docker-socket-proxy:v0.5.0 With the panel. The panel's own filtered Docker API. Panel PostgreSQL postgres:18.6-alpine With the panel, and required by it: accounts, decisions and identity, never runtime observations. The enabled Compose features determine the permanent footprint. Bridges and toolbox containers are created on demand and removedwhen done. The panel is one process Pages, API, events and WebSockets all answer on one port, from one Nodeprocess: a session cookie has one origin, and a panel split across two portswould need a proxy in front of it to have one. A small HTTP server dispatches/api/* to Hono, /ws/* to the authorised upgrade handler and everything elseto Next's App Router — seeADR 0036. It signs people in itself. PORTTA_AUTH_MODE=disabled answers everybody as thelocal operator and is only allowed on loopback; required gives it accounts,roles, sessions, ptt_ tokens and an optional second factor, all in its owndatabase (ADR 0035,ADR 0038,ADR 0039). PostgreSQL is a boot dependencyrather than a feature: the panel refuses to start without it(ADR 0037). For implementation boundaries and command development, see Monorepo layout. Networks portta is external, created by bootstrap, and shared by everyproject.Its lifecycle is independent of both the gateway stack and the projects: itsurvives portta down and is never removed automatically. portta-control is created with internal: true, so it has no routeoff the host. Only Traefik and the socket proxy are on it. This is what keepsthe Docker API away from anything that handles network traffic. portta-web exists only when the panel is enabled. It is alsointernal: true, and carries nothing but the panel and its own socket proxy.The two proxies are separate because their permission sets are:Traefik's is read-only, the panel's adds the container lifecycle(ADR 0008). portta-data also exists only with the panel. It is internal: trueand carries only the panel and PostgreSQL. The database has no published port,never joins portta, and keeps data in a named volume. It persists typedpreferences and stable identity while Docker, Git and Traefik remain the livesources of runtime observations. See Panel persistence. <project>_default is each project's own network, created by its ownCompose file. Postgres, Redis, queues and search live here and nowhere else.Traefik has no route to these networks and never needs one. A service that should be reachable through the gateway joins both itsprivate network and the shared one. Nothing else changes about it. How a request is routed demo-a-web.localhost resolves to 127.0.0.1 (seeDevelop applications locally). Traefik, holding 127.0.0.1:80, matches the Host header. The matching router points at a service Traefik built from the container'slabels, and dials the container over the portta network, pinnedby providers.docker.network so a multi-homed container is never reachedthrough a private network. The application answers on its own internal port. Nothing was published. How a service is discovered Traefik's Docker provider watches the event stream through the socket proxy.exposedByDefault=false means a container is ignored unless it setstraefik.enable=true. For an opted-in container with no explicit rule, the hostname comes fromproviders.docker.defaultRule, a template over the labels Compose alreadyinjects (ADR 0005): So a project never writes its own name into a routing rule, and a new worktreegets new hostnames by changing one environment variable. Lifecycle independence This matters enough to be a design constraint rather than a nice property: portta down stops two containers. Every application keeps running. portta up rediscovers whatever is already running. portta restart does not restart a single application container. Tearing down a project leaves the gateway healthy and the shared network intact. tests/e2e/lifecycle.test.sh asserts all of it. The panel may still operate a project on request, without owning it(ADR 0030): start, stop andrestart by iterating the containers it can already see, and rebuild or take aproject down through one opt-in runner (PORTTA_RUNNER=true) whose command isfixed at creation. portta down still stops only the gateway. Ownership Everything the gateway creates carries: Every path that stops or removes anything checks that label first. There is nocode path that can remove a consumer container, network or volume. Profiles Profile Reachable from TLS local loopback off by default remote-private the tailnet only optional remote-public the internet, opt-in ACME wildcard Profiles are Compose overlays that add only the keys they change, over a shareddocker/compose/compose.yaml (ADR 0003). What the gateway deliberately cannot do It does not own a project's containers, volumes or release cycle. On requestit may start, stop or restart what it can see, in Compose dependency order,and it may ask Compose to rebuild or take a project down through the opt-inrunner (ADR 0030). Rebuildpreserves volumes. Removal is two named modes — keep data, or include localdata — and the Compose project name is typed back, on the server. Nothingon GitHub is touched. It cannot repair a misconfigured project. doctorand analyze still only observe.","headings":[{"id":"portta-architecture","text":"Portta architecture","level":1,"line":0},{"id":"the-one-idea","text":"The one idea","level":2,"line":2},{"id":"components","text":"Components","level":2,"line":12},{"id":"the-panel-is-one-process","text":"The panel is one process","level":3,"line":27},{"id":"networks","text":"Networks","level":2,"line":47},{"id":"how-a-request-is-routed","text":"How a request is routed","level":2,"line":95},{"id":"how-a-service-is-discovered","text":"How a service is discovered","level":2,"line":106},{"id":"lifecycle-independence","text":"Lifecycle independence","level":2,"line":123},{"id":"ownership","text":"Ownership","level":2,"line":140},{"id":"profiles","text":"Profiles","level":2,"line":152},{"id":"what-the-gateway-deliberately-cannot-do","text":"What the gateway deliberately cannot do","level":2,"line":163}],"kind":"markdown"},{"slug":"project-model","title":"Projects, environments and services","description":"Understand Projects, Environments and Services.","source":"docs/product/concepts/project-model.md","audience":"user","section":"Concepts","category":"","url":"/docs/project-model","markdown":"# Projects, environments and services\n\nA **Project** is the product you organize in Portta. It can contain multiple repositories and running environments.\n\nAn **Environment** is identified by its Compose project name. Each checkout or worktree needs a distinct namespace and its own volumes. A **Service** is a containerized component within that environment, such as `web` or `postgres`.\n\nThe example Project `demo-shop` can have an Environment named `demo-shop-development`, containing `web` and `postgres`. Only the HTTP service joins the gateway network. The database remains private.\n\nPortta observes running state through Docker; it stores project associations and preferences in the panel database. These are separate sources of truth.\n\nSee [Manage projects](../guides/projects.md), [Manage environments](../guides/environments.md), and [Persistence](persistence.md).\n","text":"Projects, environments and services A Project is the product you organize in Portta. It can contain multiple repositories and running environments. An Environment is identified by its Compose project name. Each checkout or worktree needs a distinct namespace and its own volumes. A Service is a containerized component within that environment, such as web or postgres. The example Project demo-shop can have an Environment named demo-shop-development, containing web and postgres. Only the HTTP service joins the gateway network. The database remains private. Portta observes running state through Docker; it stores project associations and preferences in the panel database. These are separate sources of truth. See Manage projects, Manage environments, and Persistence.","headings":[{"id":"projects-environments-and-services","text":"Projects, environments and services","level":1,"line":0}],"kind":"markdown"},{"slug":"addresses-and-access","title":"Addresses and access","description":"Understand project domains, panel addresses and exposure decisions.","source":"docs/product/concepts/addresses-and-access.md","audience":"user","section":"Concepts","category":"","url":"/docs/addresses-and-access","markdown":"# Addresses and access\n\nPortta asks three separate questions. The Settings pages follow the same split.\nMixing them is what makes a hostname look like a public URL, or a panel change\nlook like it published every project.\n\nOpen [Project addresses](http://127.0.0.1:8081/settings/general/project-domain),\n[Project access](http://127.0.0.1:8081/settings/general/project-access), or\n[Panel](http://127.0.0.1:8081/settings/general/panel) to edit the managed keys\nfrom the panel.\n\n## The three decisions\n\n1. **What a service is called.** The base every project hostname is built on.\n2. **Who can reach Traefik.** Whether that name answers on this machine, a VPN,\n or the internet.\n3. **How the panel is reached.** Independent of the first two. Publishing the\n panel never publishes a project.\n\nA name is not an exposure. `loja-web.example.com` is only a name until something\nis listening where that name resolves, and only a public name if Traefik is\nbound to the internet on purpose.\n\n## Project addresses\n\nPortta derives every project hostname. It does not store one per service.\n\n```text\n<project>-<service>.<base>\n```\n\nA Compose project named `loja` with services `web` and `api` becomes:\n\n```text\nloja-web.localhost\nloja-api.localhost\n```\n\nOne DNS label holds both parts, so a single wildcard (`*.example.com` or\n`*.1-2-3-4.sslip.io`) covers every project. Nested names such as\n`web.loja.example.com` would sit below that wildcard.\n\nThe base is a mode, not a value you have to keep in sync:\n\n| Mode | Base | When |\n|---|---|---|\n| This machine | `localhost` | You are sitting at the host |\n| Automatic | `<ip-with-dashes>.sslip.io` (or `nip.io`) | A host with an address and no domain |\n| Your own domain | the domain you type | You already have a wildcard |\n\nChanging the mode re-labels every project at once. Who may open those names is\nstill Public access or VPN.\n\n## Localhost\n\n`.localhost` resolves to loopback with no DNS server and no `/etc/hosts` line.\nIt is the default, and it only works on this machine. A panel reached from\nelsewhere must not advertise `*.localhost` as a clickable URL.\n\n## Automatic domains\n\n[sslip.io](https://sslip.io) and [nip.io](https://nip.io) answer for any name\nthat embeds an IPv4 address. No record, no account, no registration:\n\n```text\nloja-web.203-0-113-10.sslip.io → 203.0.113.10\n```\n\nThe dashed form keeps the address in one label, so the project hostname stays\none level below the base. These names resolve from anywhere the address is\nroutable. They are still not public until Public access is turned on.\n\n## Custom domain\n\nA domain you own, with a wildcard `A` (or `AAAA`) record:\n\n```text\n*.dev.example.com → this host\n```\n\nUntil that record exists, the names are formed correctly and nothing answers.\n[DNS](#dns) can create the record through Cloudflare; it can also be created by\nhand.\n\n## Project access\n\n[Project access](http://127.0.0.1:8081/settings/general/project-access) keeps\nthe reachability decision in one place. Choose this machine, the Tailscale VPN,\nor the internet; the panel derives the matching gateway profile and bind\naddress instead of asking you to coordinate them by hand.\n\n### Public access\n\n[Project access](http://127.0.0.1:8081/settings/general/project-access) decides whether the internet\ncan reach Traefik on ports 80 and 443.\n\nIt changes **who can reach Traefik**. That is all.\n\nIt does not change the hostname formula. It does not publish a service that has\nnot set `traefik.enable=true`. It does not publish the panel, the Traefik\ndashboard, or any datastore.\n\nWhen project addresses already use a domain you own, that domain is the public\nnamespace unless you set a different one. When they use `localhost`, a public\ndomain is required: `localhost` is not reachable from the internet. When they\nuse an automatic domain, that name can be reused.\n\nTurning this on is explicit. Changing a project domain or the panel URL never\nturns it on.\n\nSee [Enable public access](../guides/public-access.md) for the CLI and what is never\npublished.\n\n## Traefik\n\nTraefik is the reverse proxy that receives every hostname and forwards it to\nthe matching container. The default rule is the formula above.\n\nIts own dashboard is a separate process, published on loopback by the normal\nhost attachment (default `http://127.0.0.1:8080/dashboard/`). It has no login of\nits own, so it is never routed on a domain. When Traefik shares the Tailscale\nnetwork namespace, that port can also be reached on the tailnet; the Settings\npage calls this out explicitly.\n\nSee [Project access](http://127.0.0.1:8081/settings/general/project-access) for listen address\nand ports, and [Traefik](http://127.0.0.1:8081/settings/general/traefik) for the\ndashboard.\n\n## TLS\n\nCertificates for project hostnames. They are not assumed just because a domain\nis set.\n\n- Off: only HTTP is served.\n- Local CA: a certificate you trust on this machine.\n- Let’s Encrypt: ACME. DNS-01 issues one wildcard and needs a DNS credential.\n HTTP-01 issues one certificate per hostname and needs port 80 reachable from\n the internet.\n\nAutomatic domains do not get a wildcard from Let’s Encrypt DNS-01. A hostname\nyou own does, once [DNS](#dns) can answer the challenge.\n\nSee [Configure DNS and TLS](../guides/dns-and-tls.md).\n\n## The panel\n\nThe panel process and the address you type in a browser are different things.\n\n**Network** is the host-facing publication: port (default `8081`) and bind\n(default `127.0.0.1`). In direct modes this publishes the panel container. In\npublic mode the same values belong to its dedicated Traefik entrypoint, and\n`0.0.0.0` is only selected by that explicit public choice.\n\n**Address** is how a browser reaches it:\n\n| Choice | Result | Sign-in |\n|---|---|---|\n| This machine only | `http://127.0.0.1:8081` | Optional |\n| Over the VPN | the tailnet address | Required |\n| On the panel port, from the internet | the panel's own port, not 80/443 | Required |\n| On a hostname | a name, routed by Traefik | Required |\n\nThe default is this machine. Choosing a hostname never publishes your projects.\n\n### Subdomain of the configured domain\n\nYou type only the label. The base is the one already configured for projects:\n\n```text\n[ portta ] .localhost\n→ http://portta.localhost\n```\n\n### Custom panel domain\n\nA hostname of your own, for example `portta.example.com`. It must resolve to\nthis server. [Configure DNS](#dns) and [TLS](#tls) if they are not already\nset. HTTPS on this mode needs TLS enabled.\n\nSee [Panel](http://127.0.0.1:8081/settings/general/panel).\n\n## VPN\n\n[Project access](http://127.0.0.1:8081/settings/general/project-access) can attach Traefik to a\nTailscale tailnet. Projects become reachable on that network, not on the public\nNIC. An optional private domain overrides the project base for those names.\n\nThe self-contained option runs Tailscale in a container and lets Traefik share\nits network namespace. If Tailscale or another VPN already runs on the host,\nchoose the existing-interface option and enter that private interface address.\nPortta still refuses `0.0.0.0` in both cases.\n\nTurning Tailscale on does not publish anything on the internet. Public access\nstays a separate switch.\n\n## Local, VPN, and public exposure\n\n| Who should reach projects | What to set |\n|---|---|\n| Only this machine | Project addresses on localhost. Gateway on `127.0.0.1`. Public access off. |\n| A private network / tailnet | [Project access](http://127.0.0.1:8081/settings/general/project-access) set to VPN. Optional private domain. |\n| The internet | [Project access](http://127.0.0.1:8081/settings/general/project-access) set to internet, a public domain, and usually [TLS](http://127.0.0.1:8081/settings/general/tls). |\n\nThe panel row in that table is independent. A public panel on its own port does\nnot open 80/443 for applications. A public gateway does not move the panel.\n\n## DNS\n\nEach scenario needs a different record, or none:\n\n| Scenario | DNS |\n|---|---|\n| localhost | None |\n| Automatic (`sslip.io` / `nip.io`) | None |\n| Your own project domain | `*.<domain>` → this host |\n| Public access on a different domain | `*.<public-domain>` → this host |\n| Custom panel hostname | that hostname → this host |\n| Let’s Encrypt DNS-01 | the provider must be able to create a TXT record |\n\n[DNS settings](http://127.0.0.1:8081/settings/general/dns) are optional\nCloudflare automation for the wildcard and the DNS-01 challenge. They do not\nchoose the project domain.\n\nSee [Configure DNS and TLS](../guides/dns-and-tls.md) and [Configure Cloudflare DNS](../guides/cloudflare.md).\n","text":"Addresses and access Portta asks three separate questions. The Settings pages follow the same split.Mixing them is what makes a hostname look like a public URL, or a panel changelook like it published every project. Open Project addresses,Project access, orPanel to edit the managed keysfrom the panel. The three decisions What a service is called. The base every project hostname is built on. Who can reach Traefik. Whether that name answers on this machine, a VPN,or the internet. How the panel is reached. Independent of the first two. Publishing thepanel never publishes a project. A name is not an exposure. loja-web.example.com is only a name until somethingis listening where that name resolves, and only a public name if Traefik isbound to the internet on purpose. Project addresses Portta derives every project hostname. It does not store one per service. A Compose project named loja with services web and api becomes: One DNS label holds both parts, so a single wildcard (*.example.com or*.1-2-3-4.sslip.io) covers every project. Nested names such asweb.loja.example.com would sit below that wildcard. The base is a mode, not a value you have to keep in sync: Mode Base When This machine localhost You are sitting at the host Automatic <ip-with-dashes>.sslip.io (or nip.io) A host with an address and no domain Your own domain the domain you type You already have a wildcard Changing the mode re-labels every project at once. Who may open those names isstill Public access or VPN. Localhost .localhost resolves to loopback with no DNS server and no /etc/hosts line.It is the default, and it only works on this machine. A panel reached fromelsewhere must not advertise *.localhost as a clickable URL. Automatic domains sslip.io and nip.io answer for any namethat embeds an IPv4 address. No record, no account, no registration: The dashed form keeps the address in one label, so the project hostname staysone level below the base. These names resolve from anywhere the address isroutable. They are still not public until Public access is turned on. Custom domain A domain you own, with a wildcard A (or AAAA) record: Until that record exists, the names are formed correctly and nothing answers.DNS can create the record through Cloudflare; it can also be created byhand. Project access Project access keepsthe reachability decision in one place. Choose this machine, the Tailscale VPN,or the internet; the panel derives the matching gateway profile and bindaddress instead of asking you to coordinate them by hand. Public access Project access decides whether the internetcan reach Traefik on ports 80 and 443. It changes who can reach Traefik. That is all. It does not change the hostname formula. It does not publish a service that hasnot set traefik.enable=true. It does not publish the panel, the Traefikdashboard, or any datastore. When project addresses already use a domain you own, that domain is the publicnamespace unless you set a different one. When they use localhost, a publicdomain is required: localhost is not reachable from the internet. When theyuse an automatic domain, that name can be reused. Turning this on is explicit. Changing a project domain or the panel URL neverturns it on. See Enable public access for the CLI and what is neverpublished. Traefik Traefik is the reverse proxy that receives every hostname and forwards it tothe matching container. The default rule is the formula above. Its own dashboard is a separate process, published on loopback by the normalhost attachment (default http://127.0.0.1:8080/dashboard/). It has no login ofits own, so it is never routed on a domain. When Traefik shares the Tailscalenetwork namespace, that port can also be reached on the tailnet; the Settingspage calls this out explicitly. See Project access for listen addressand ports, and Traefik for thedashboard. TLS Certificates for project hostnames. They are not assumed just because a domainis set. Off: only HTTP is served. Local CA: a certificate you trust on this machine. Let’s Encrypt: ACME. DNS-01 issues one wildcard and needs a DNS credential.HTTP-01 issues one certificate per hostname and needs port 80 reachable fromthe internet. Automatic domains do not get a wildcard from Let’s Encrypt DNS-01. A hostnameyou own does, once DNS can answer the challenge. See Configure DNS and TLS. The panel The panel process and the address you type in a browser are different things. Network is the host-facing publication: port (default 8081) and bind(default 127.0.0.1). In direct modes this publishes the panel container. Inpublic mode the same values belong to its dedicated Traefik entrypoint, and0.0.0.0 is only selected by that explicit public choice. Address is how a browser reaches it: Choice Result Sign-in This machine only http://127.0.0.1:8081 Optional Over the VPN the tailnet address Required On the panel port, from the internet the panel's own port, not 80/443 Required On a hostname a name, routed by Traefik Required The default is this machine. Choosing a hostname never publishes your projects. Subdomain of the configured domain You type only the label. The base is the one already configured for projects: Custom panel domain A hostname of your own, for example portta.example.com. It must resolve tothis server. Configure DNS and TLS if they are not alreadyset. HTTPS on this mode needs TLS enabled. See Panel. VPN Project access can attach Traefik to aTailscale tailnet. Projects become reachable on that network, not on the publicNIC. An optional private domain overrides the project base for those names. The self-contained option runs Tailscale in a container and lets Traefik shareits network namespace. If Tailscale or another VPN already runs on the host,choose the existing-interface option and enter that private interface address.Portta still refuses 0.0.0.0 in both cases. Turning Tailscale on does not publish anything on the internet. Public accessstays a separate switch. Local, VPN, and public exposure Who should reach projects What to set Only this machine Project addresses on localhost. Gateway on 127.0.0.1. Public access off. A private network / tailnet Project access set to VPN. Optional private domain. The internet Project access set to internet, a public domain, and usually TLS. The panel row in that table is independent. A public panel on its own port doesnot open 80/443 for applications. A public gateway does not move the panel. DNS Each scenario needs a different record, or none: Scenario DNS localhost None Automatic (sslip.io / nip.io) None Your own project domain *.<domain> → this host Public access on a different domain *.<public-domain> → this host Custom panel hostname that hostname → this host Let’s Encrypt DNS-01 the provider must be able to create a TXT record DNS settings are optionalCloudflare automation for the wildcard and the DNS-01 challenge. They do notchoose the project domain. See Configure DNS and TLS and Configure Cloudflare DNS.","headings":[{"id":"addresses-and-access","text":"Addresses and access","level":1,"line":0},{"id":"the-three-decisions","text":"The three decisions","level":2,"line":11},{"id":"project-addresses","text":"Project addresses","level":2,"line":23},{"id":"localhost","text":"Localhost","level":2,"line":53},{"id":"automatic-domains","text":"Automatic domains","level":2,"line":59},{"id":"custom-domain","text":"Custom domain","level":2,"line":72},{"id":"project-access","text":"Project access","level":2,"line":84},{"id":"public-access","text":"Public access","level":3,"line":91},{"id":"traefik","text":"Traefik","level":2,"line":113},{"id":"tls","text":"TLS","level":2,"line":128},{"id":"the-panel","text":"The panel","level":2,"line":144},{"id":"subdomain-of-the-configured-domain","text":"Subdomain of the configured domain","level":3,"line":164},{"id":"custom-panel-domain","text":"Custom panel domain","level":3,"line":173},{"id":"vpn","text":"VPN","level":2,"line":181},{"id":"local-vpn-and-public-exposure","text":"Local, VPN, and public exposure","level":2,"line":195},{"id":"dns","text":"DNS","level":2,"line":206}],"kind":"markdown"},{"slug":"networking","title":"Networking","description":"Understand Docker networks, namespaces and hostname routing.","source":"docs/product/concepts/networking.md","audience":"user","section":"Concepts","category":"","url":"/docs/networking","markdown":"# Networking\n\n## Container ports vs host ports\n\nThis distinction is the whole reason the gateway exists.\n\n**Container port.** The port a process listens on inside its container. It is\nnamespaced per container. Twenty containers can all listen on 5432; they cannot\nsee each other unless they share a network.\n\n**Host port.** Created only by a `ports:` entry, which asks the daemon to bind\na port on the machine. Exactly one process can hold it.\n\nAlmost every \"port already in use\" in local development comes from publishing\nsomething that did not need publishing.\n\n### The rule\n\n> Never change an internal port to avoid a conflict.\n\nIf `3000` collides, the fix is to stop publishing it, not to move to `3001`.\nKeeping internal ports identical to production is worth protecting; the host\nport was an accident of tooling.\n\n| Service | Internal | Published on the host? |\n|---|---|---|\n| web | 3000 | no, routed by hostname |\n| api | 8000 | no, routed by hostname |\n| postgres | 5432 | no, use `portta access open` |\n| redis | 6379 | no, use `portta access open` |\n| Traefik | 80 / 443 | yes, once, for the whole machine |\n\n## The three kinds of network\n\n### `portta`: shared, external\n\nCreated by `bootstrap`, owned by the gateway, joined by every service that\nshould receive HTTP traffic.\n\n```yaml\nnetworks:\n portta:\n external: true\n name: portta\n```\n\n`external: true` means Compose expects it to exist and will neither create nor\nremove it, which is exactly the decoupling we want. It survives\n`portta down` and\nis never removed automatically, because other projects are attached.\n\nOnly HTTP-facing services join it. A database on this network is reachable by\nevery other project on the host; `doctor` warns when it finds one.\n\n### `portta-control`: internal\n\n`internal: true` gives it no route to the outside world. It carries exactly one\nconversation: Traefik asking the socket proxy what containers exist.\n\n### `<project>_default`: private, per project\n\nCompose creates one per project, named from `COMPOSE_PROJECT_NAME`. This is\nwhere Postgres, Redis, queues and search belong. Two projects get two networks\nand cannot resolve or reach each other. That is asserted in\n`tests/e2e/parallel.test.sh`.\n\n## Multi-homed services\n\nA service reachable through the gateway sits on two networks:\n\n```yaml\nservices:\n web:\n networks:\n - default # to reach postgres, redis, ...\n - portta # to receive traffic from Traefik\n```\n\nBecause the container then has two addresses, Traefik has to be told which one\nto dial. Two settings cover it:\n\n- `providers.docker.network=portta` on the gateway (the default), and\n- `traefik.docker.network=portta` on the container (explicit, per service).\n\nWithout them Traefik may pick the private address, which it cannot reach.\n\n## Hostnames\n\n```text\n<compose-project>-<service>.<domain>\n```\n\n| Environment | Hostname |\n|---|---|\n| `base-empresarial` / `web` | `base-empresarial-web.localhost` |\n| `base-empresarial-issue59` / `api` | `base-empresarial-issue59-api.localhost` |\n| on a VPS | `base-empresarial-web.vpn.dev.example.com` |\n\nBoth parts are normalised to lowercase `[a-z0-9-]`. One subdomain level, not\ntwo, so a single wildcard certificate covers everything\n([ADR 0005](../../development/adr/0005-hostname-convention.md)). How the base is chosen, and how\nthat is not the same as public access, is\n[Addresses and access](addresses-and-access.md).\n\nTo override, set an explicit rule. It wins over the derived hostname:\n\n```yaml\nlabels:\n - \"traefik.http.routers.myapp.rule=Host(`something-else.localhost`)\"\n```\n\n## Traefik service names share one namespace\n\nThis one bites people. Traefik service names are flat across the whole host: if\ntwo projects both declare\n\n```yaml\n- \"traefik.http.services.web.loadbalancer.server.port=3000\"\n```\n\nTraefik merges them into **one** load balancer with two backends, and project A\nstarts receiving project B's traffic. Always prefix with the namespace:\n\n```yaml\n- \"traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=3000\"\n```\n\n`doctor` reports the collision if it happens.\n\n> **Write labels in list form.** Compose interpolates `${VAR}` inside a list\n> entry but **not** inside a mapping key. In map form the service name stays the\n> literal `${COMPOSE_PROJECT_NAME}` and every worktree collapses onto one\n> service. `doctor` fails on a literal `${` in a Traefik label.\n\n## Non-HTTP traffic\n\nTraefik routes HTTP by `Host` header. Raw TCP protocols, the PostgreSQL and\nRedis wire protocols among them, carry no hostname on the connection, so they\ncannot be multiplexed onto one port that way. They are reached through\nper-session loopback bridges instead: see [Open a TCP bridge](../guides/tcp-access.md).\n\n## Diagnostics\n\n```bash\nportta status # profile, listeners, route count\nportta urls # every hostname currently served\nportta doctor # binds, exposure, collisions, isolation\ndocker network inspect portta\n```\n","text":"Networking Container ports vs host ports This distinction is the whole reason the gateway exists. Container port. The port a process listens on inside its container. It isnamespaced per container. Twenty containers can all listen on 5432; they cannotsee each other unless they share a network. Host port. Created only by a ports: entry, which asks the daemon to binda port on the machine. Exactly one process can hold it. Almost every \"port already in use\" in local development comes from publishingsomething that did not need publishing. The rule Never change an internal port to avoid a conflict. If 3000 collides, the fix is to stop publishing it, not to move to 3001.Keeping internal ports identical to production is worth protecting; the hostport was an accident of tooling. Service Internal Published on the host? web 3000 no, routed by hostname api 8000 no, routed by hostname postgres 5432 no, use portta access open redis 6379 no, use portta access open Traefik 80 / 443 yes, once, for the whole machine The three kinds of network portta: shared, external Created by bootstrap, owned by the gateway, joined by every service thatshould receive HTTP traffic. external: true means Compose expects it to exist and will neither create norremove it, which is exactly the decoupling we want. It survivesportta down andis never removed automatically, because other projects are attached. Only HTTP-facing services join it. A database on this network is reachable byevery other project on the host; doctor warns when it finds one. portta-control: internal internal: true gives it no route to the outside world. It carries exactly oneconversation: Traefik asking the socket proxy what containers exist. <project>_default: private, per project Compose creates one per project, named from COMPOSE_PROJECT_NAME. This iswhere Postgres, Redis, queues and search belong. Two projects get two networksand cannot resolve or reach each other. That is asserted intests/e2e/parallel.test.sh. Multi-homed services A service reachable through the gateway sits on two networks: Because the container then has two addresses, Traefik has to be told which oneto dial. Two settings cover it: providers.docker.network=portta on the gateway (the default), and traefik.docker.network=portta on the container (explicit, per service). Without them Traefik may pick the private address, which it cannot reach. Hostnames Environment Hostname base-empresarial / web base-empresarial-web.localhost base-empresarial-issue59 / api base-empresarial-issue59-api.localhost on a VPS base-empresarial-web.vpn.dev.example.com Both parts are normalised to lowercase [a-z0-9-]. One subdomain level, nottwo, so a single wildcard certificate covers everything(ADR 0005). How the base is chosen, and howthat is not the same as public access, isAddresses and access. To override, set an explicit rule. It wins over the derived hostname: Traefik service names share one namespace This one bites people. Traefik service names are flat across the whole host: iftwo projects both declare Traefik merges them into one load balancer with two backends, and project Astarts receiving project B's traffic. Always prefix with the namespace: doctor reports the collision if it happens. Write labels in list form. Compose interpolates ${VAR} inside a listentry but not inside a mapping key. In map form the service name stays theliteral ${COMPOSE_PROJECT_NAME} and every worktree collapses onto oneservice. doctor fails on a literal ${ in a Traefik label. Non-HTTP traffic Traefik routes HTTP by Host header. Raw TCP protocols, the PostgreSQL andRedis wire protocols among them, carry no hostname on the connection, so theycannot be multiplexed onto one port that way. They are reached throughper-session loopback bridges instead: see Open a TCP bridge. Diagnostics","headings":[{"id":"networking","text":"Networking","level":1,"line":0},{"id":"container-ports-vs-host-ports","text":"Container ports vs host ports","level":2,"line":2},{"id":"the-rule","text":"The rule","level":3,"line":16},{"id":"the-three-kinds-of-network","text":"The three kinds of network","level":2,"line":32},{"id":"portta-shared-external","text":"portta: shared, external","level":3,"line":34},{"id":"portta-control-internal","text":"portta-control: internal","level":3,"line":54},{"id":"project_default-private-per-project","text":"<project>_default: private, per project","level":3,"line":59},{"id":"multi-homed-services","text":"Multi-homed services","level":2,"line":66},{"id":"hostnames","text":"Hostnames","level":2,"line":86},{"id":"traefik-service-names-share-one-namespace","text":"Traefik service names share one namespace","level":2,"line":111},{"id":"non-http-traffic","text":"Non-HTTP traffic","level":2,"line":134},{"id":"diagnostics","text":"Diagnostics","level":2,"line":141}],"kind":"markdown"},{"slug":"persistence","title":"Persistence","description":"Understand durable panel decisions and live runtime observations.","source":"docs/product/concepts/persistence.md","audience":"user","section":"Concepts","category":"","url":"/docs/persistence","markdown":"# Persistence\n\nThe administration panel keeps its durable decisions in PostgreSQL: preferences,\nproject metadata, tasks and integration configuration. It is part of the panel,\nnot part of the HTTP gateway, and it never stores runtime observations as a\nsource of truth.\n\n**PostgreSQL is required.** A panel that starts without it can show Docker and\nnothing else, and every write it accepts is lost, so it says what is missing and\nexits instead. The managed mode selects `docker/compose/features/db.yaml` with the panel.\nExternal mode selects the panel without the local database. Both modes authenticate\nand apply migrations before the HTTP listener starts.\n\n## What is persisted\n\nDecisions, and a bounded history of the development flow:\n\n- one stable gateway instance identity;\n- **People and access** (`users`, `sessions`, `accounts`, `verifications`,\n `api_keys`, `two_factors`, `project_members`): who may sign in, and which\n Projects a `developer` or `viewer` can see. The tables exist from the first\n migration; the panel starts using them when authentication is turned on;\n- **Projects** (`projects`): the product the operator recognises, its slug,\n description and its place under Projects Home; which environments it\n adopted (`project_environments`), and why;\n- **Repositories** (`repositories`): a Project's git repositories, local\n first — a path, a remote, a role — with a GitHub repository as an optional\n binding;\n- **Tasks** (`tasks`, `task_notes`, `task_attachments`, `task_environments`):\n Portta's own unit of work, with subtasks, notes, attached files and the\n environments a task is worked in; `task_github_links` binds a task to a\n projected issue and remembers whether the last local edit reached GitHub;\n- **Work sessions** (`work_sessions`): who worked on what, since when,\n and what came out;\n- **Activity** (`activity_events`): what happened — a task moved, a session\n started, an environment rebuilt, a commit landed — pruned in code after\n ninety days or five thousand rows per Project;\n- **Audit** (`audit_log`): the sensitive writes — who signed in, who changed a\n role, who destroyed an environment — so \"who did that\" is answerable months\n later. Never a request body, a password, a hash or a token;\n- environment identity (`environments`, one row per `COMPOSE_PROJECT_NAME`\n ever seen, with `working_dir` and `config_files` as Docker last recorded\n them, so an environment whose containers are gone can be started again\n through the runner, or forgotten) and the closed catalogue of global,\n environment and service preferences (`settings`, `environment_settings`,\n `service_settings`);\n- the GitHub projection (`github_installations`, `github_repositories`,\n `github_issues`, `github_issue_relationships`, `github_sync_state`): a\n cache of a remote source of truth, every row with its age.\n\nContainer state, health, ports, networks, URLs, logs, the repository scans\nand Traefik status still come from their live owners. A stopped container\ndisappears from the next Docker snapshot; PostgreSQL is not a stale inventory\ncache. `packages/db/tests/schema.test.ts` asserts that no table for any of them\nexists.\n\nMost of that state is true only of this machine. [ADR 0016](../../development/adr/0016-state-that-could-be-shared.md)\nclassifies what could ever be shared between two gateways (project and user\ndecisions) and what must never be (runtime observations and instance\nconfiguration). No synchronisation is implemented.\n\n\n## Isolation and lifecycle\n\nPostgreSQL uses the pinned image in `docker/compose/features/db.yaml`, a named\nvolume and the dedicated `portta-data` network. The network is `internal`; the\ndatabase publishes no host port and never joins the shared `portta` HTTP\nnetwork. `doctor` fails if either invariant is broken.\n\n`portta web up` generates the database password in the git-ignored `.env`\nwhen needed. The panel API reports only whether that setting exists and never\nreturns its value. `portta web down`, `portta down` and subsequent\n`up` operations preserve the named volume. `portta dev --reset` (or\n`portta reset`) is the command that stops Portta-managed stacks, drops their\nvolumes including the panel database, and starts the checkout again.\n\nA connection that drops *after* boot is a different thing from a missing one:\nthe panel keeps serving every Docker-backed page, `/api/health` and the\nexisting read surfaces, Overview and diagnostics show a persistence warning,\nand only an operation that needs stored state returns 503.\n\n\nSee [Back up and restore the panel](../guides/backup-restore.md) for operations and [Develop the database schema](../../development/database-development.md) for migrations.\n","text":"Persistence The administration panel keeps its durable decisions in PostgreSQL: preferences,project metadata, tasks and integration configuration. It is part of the panel,not part of the HTTP gateway, and it never stores runtime observations as asource of truth. PostgreSQL is required. A panel that starts without it can show Docker andnothing else, and every write it accepts is lost, so it says what is missing andexits instead. The managed mode selects docker/compose/features/db.yaml with the panel.External mode selects the panel without the local database. Both modes authenticateand apply migrations before the HTTP listener starts. What is persisted Decisions, and a bounded history of the development flow: one stable gateway instance identity; People and access (users, sessions, accounts, verifications,api_keys, two_factors, project_members): who may sign in, and whichProjects a developer or viewer can see. The tables exist from the firstmigration; the panel starts using them when authentication is turned on; Projects (projects): the product the operator recognises, its slug,description and its place under Projects Home; which environments itadopted (project_environments), and why; Repositories (repositories): a Project's git repositories, localfirst — a path, a remote, a role — with a GitHub repository as an optionalbinding; Tasks (tasks, task_notes, task_attachments, task_environments):Portta's own unit of work, with subtasks, notes, attached files and theenvironments a task is worked in; task_github_links binds a task to aprojected issue and remembers whether the last local edit reached GitHub; Work sessions (work_sessions): who worked on what, since when,and what came out; Activity (activity_events): what happened — a task moved, a sessionstarted, an environment rebuilt, a commit landed — pruned in code afterninety days or five thousand rows per Project; Audit (audit_log): the sensitive writes — who signed in, who changed arole, who destroyed an environment — so \"who did that\" is answerable monthslater. Never a request body, a password, a hash or a token; environment identity (environments, one row per COMPOSE_PROJECT_NAMEever seen, with working_dir and config_files as Docker last recordedthem, so an environment whose containers are gone can be started againthrough the runner, or forgotten) and the closed catalogue of global,environment and service preferences (settings, environment_settings,service_settings); the GitHub projection (github_installations, github_repositories,github_issues, github_issue_relationships, github_sync_state): acache of a remote source of truth, every row with its age. Container state, health, ports, networks, URLs, logs, the repository scansand Traefik status still come from their live owners. A stopped containerdisappears from the next Docker snapshot; PostgreSQL is not a stale inventorycache. packages/db/tests/schema.test.ts asserts that no table for any of themexists. Most of that state is true only of this machine. ADR 0016classifies what could ever be shared between two gateways (project and userdecisions) and what must never be (runtime observations and instanceconfiguration). No synchronisation is implemented. Isolation and lifecycle PostgreSQL uses the pinned image in docker/compose/features/db.yaml, a namedvolume and the dedicated portta-data network. The network is internal; thedatabase publishes no host port and never joins the shared portta HTTPnetwork. doctor fails if either invariant is broken. portta web up generates the database password in the git-ignored .envwhen needed. The panel API reports only whether that setting exists and neverreturns its value. portta web down, portta down and subsequentup operations preserve the named volume. portta dev --reset (orportta reset) is the command that stops Portta-managed stacks, drops theirvolumes including the panel database, and starts the checkout again. A connection that drops after boot is a different thing from a missing one:the panel keeps serving every Docker-backed page, /api/health and theexisting read surfaces, Overview and diagnostics show a persistence warning,and only an operation that needs stored state returns 503. See Back up and restore the panel for operations and Develop the database schema for migrations.","headings":[{"id":"persistence","text":"Persistence","level":1,"line":0},{"id":"what-is-persisted","text":"What is persisted","level":2,"line":13},{"id":"isolation-and-lifecycle","text":"Isolation and lifecycle","level":2,"line":62}],"kind":"markdown"},{"slug":"security","title":"Security","description":"Understand trust boundaries, isolation, secrets and residual risks.","source":"docs/product/concepts/security.md","audience":"user","section":"Concepts","category":"","url":"/docs/security","markdown":"# Security\n\n## Threat model\n\nThe gateway is a development tool. Its job is to make it *hard to expose\nsomething by accident*, and to keep an accident's blast radius small.\n\nThe realistic risks are, in order:\n\n1. **Accidental exposure.** A database on `0.0.0.0`, a dashboard on a public\n interface, a \"temporary\" public domain nobody turned off.\n2. **Docker socket access.** The API is not namespaced; reaching it means root\n on the host.\n3. **Secret leakage.** Auth keys and API tokens in Git, logs, or shell history.\n4. **Lateral movement.** One project's compromise reaching another's database.\n\n## Exposure depends on the selected mode\n\n- `providers.docker.exposedByDefault=false`. A service is routed only when it\n sets `traefik.enable=true`.\n- The local profile binds Traefik to `127.0.0.1`. `doctor` **fails** if the\n local profile is bound anywhere else.\n- The public profile is off, and turning it on prints what will become\n reachable and asks for confirmation.\n- Databases and caches are never published and never joined to the shared\n network. `doctor` fails on a datastore published on `0.0.0.0` and warns on\n one attached to the shared network.\n- The panel's own PostgreSQL is stricter: it publishes no port at all, joins\n only an `internal` data network, and makes `doctor` fail if it appears on the\n shared HTTP network.\n\n## The Docker socket\n\nTraefik never sees it. Discovery goes through\n`tecnativa/docker-socket-proxy`, which mounts the socket **read-only** and\nallows only `CONTAINERS`, `NETWORKS`, `EVENTS`, `PING` and `VERSION`. All\nwrites are denied (`POST=0`). The proxy runs `read_only: true`, publishes no\nhost port, and lives alone with Traefik on a network created `internal: true`.\n\n`doctor` fails if the socket is mounted into Traefik, if the proxy's mount is\nwritable, if the proxy publishes a host port, or if the control network is not\ninternal.\n\n**Residual risk, stated plainly.** Discovery requires\n`GET /containers/{id}/json`, whose response includes container environment\nvariables. A compromised Traefik could therefore read secrets that consumer\nprojects pass as environment variables. This is inherent to Traefik's Docker\nprovider, not to this proxy. If that matters for a given project, pass secrets\nas files or via a secrets manager rather than env vars.\n\nSee [ADR 0002](../../development/adr/0002-docker-socket-proxy.md).\n\n## Network isolation\n\nEach project keeps its own private network. Postgres, Redis, queues and search\nstay there. Traefik has no route to those networks, and neither does any other\nproject. `tests/e2e/parallel.test.sh` asserts that one project cannot reach\nanother's database.\n\nThe shared `portta` network is the one place projects meet, and only\nHTTP-facing services join it. Anything on it is reachable by every other\nproject on the host, which is exactly why a database does not belong there.\n\n## TCP access bridges\n\nA bridge is a hole into a project's private network, so it is kept small and\nshort-lived. It binds `127.0.0.1` on a kernel-assigned port; binding anywhere\nelse needs an explicit `--bind` and a confirmation, and `doctor` **fails** on a\nbridge bound beyond loopback.\n\n`portta service publish --public` on a datastore is refused outright, not\nwarned about. Persistent forwarders join their project's network and the\ngateway's access network only, never the shared HTTP network, which `doctor`\nalso enforces.\n\n`access close` and `access gc` re-check the ownership label on the code path\nthat actually removes a container, rather than trusting the filter that found\nit.\n\n## The dashboard\n\nOff by default. When enabled it is published on its own loopback-bound port and\nattached only to Traefik's internal entrypoint, so it is never routed through\n`web`/`websecure` and cannot appear under a public wildcard domain. `doctor`\nfails if it is enabled on a non-loopback address.\n\nThe loopback bind constrains the host, not the shared network. Insecure mode\nlistens inside a namespace attached to `portta`, so while the dashboard is\nenabled **any adopted project's container can reach `http://traefik:8080`** and\nread the full routing configuration, including the hostnames and backends of\nevery other project on the host. On the Tailscale attachment the same API\nanswers at `http://tailscale:8080`. That is the cost of turning it on, it is\nwhy it is off by default, and it is the same API the panel reads for a router's\nstatus. Nothing\nsensitive to a project's own users is there, but the inventory of the host is.\n\nThe dashboard has no routed access mode. `doctor` fails a non-loopback\n`PORTTA_DASHBOARD_BIND_ADDRESS`.\n\n## Databases reached by hostname\n\nOff by default. Turning it on publishes one port per protocol; it does not\npublish a database. Three things have to line up before one answers:\n`PORTTA_TCP=true` on the gateway, `traefik.enable=true` plus TCP router\nlabels on the container, and the container on the access network. Being visible\nto the gateway is not being routed.\n\n- **Never public.** The TCP entrypoints are refused on the `remote-public`\n profile, where Traefik binds every interface. That is a refusal at profile\n resolution, not a warning, and `doctor` fails if the combination is ever\n reached another way.\n- **Where they listen** follows the profile, like everything else: loopback\n locally, the tailnet address with Tailscale, an interface you named\n otherwise.\n- **Not on the HTTP network.** An opted-in datastore joins\n `portta-access`. The shared network still carries no database; Compose\n validation and routing tests enforce that boundary.\n- **TLS is mandatory**, since the hostname lives in the handshake. Without a\n configured certificate Traefik serves a self-signed one, which\n `sslmode=require` accepts and `verify-full` does not; `doctor` says so.\n\nAuthentication is unchanged and is still the database's own. The gateway routes\nbytes and never reads a project's credentials.\n\n## The web panel\n\nThe base gateway can run without the panel. The installer enables the panel and defaults to public access with authentication required. The panel can start, stop and remove containers, so its access mode must be chosen deliberately.\n\n- **Network.** The local mode binds loopback. VPN routing and the dedicated public panel\n entrypoint are separate, explicit overlays; the public overlay does not\n publish the application's `web`/`websecure` entrypoints.\n- **Authentication, which the panel does itself.** Every access mode but `local`\n — `tailscale`, `public`, `vpn`, `domain` — requires `PORTTA_AUTH_MODE=required`\n and is refused without it, by `portta web up`, by `portta up`, and by the\n panel's own process at boot. The\n panel signs people in against its own database: a session cookie for a person,\n a `ptt_` Bearer token for a CLI or an agent, and a role that decides what each\n may do. Every operation declares the permission it needs, `401` and `403` mean\n different things, and a revoked token or a banned user stops working on the\n next request rather than the next sign-in. `PORTTA_AUTH_MODE=disabled` answers\n everybody as the local operator and is refused anywhere but loopback. A routed\n panel also defaults to read-only, and `doctor` fails if either is missing. See\n [Configure authentication](../guides/authentication.md) and\n [ADR 0035](../../development/adr/0035-authentication-lives-in-the-panel.md).\n- **Live channels.** The event stream needs `activity:read` and filters every\n event against the principal that opened it; an event about a Project somebody\n does not reach is never delivered, and an event about no Project at all goes\n only to `scope: 'all'`. The log WebSocket is authorised *before* the\n handshake becomes a socket — `logs:read`, scoped to the Project that adopted\n the environment — and a refusal is answered as HTTP and then closed, never\n left hanging. One `upgrade` listener owns every `/ws/…` path, including the\n ones it refuses. Query parameters are validated before anything uses them,\n and the stream comes from the Docker API through the panel's own socket\n proxy: nothing is concatenated into a command.\n- **Traefik configuration.** The panel may write three filenames in\n `config/traefik/dynamic/` and refuses every other path in its own process.\n See [ADR 0011](../../development/adr/0011-bounded-traefik-write-surface.md).\n- **Temporary shares.** A share is one additional hostname for one service,\n with a mandatory expiry, on a network the gateway already answers: it exposes\n a hostname, never a network. Sharing a datastore, a service off the shared\n network, anything public without `PUBLIC_ENABLED`, and a password over\n plaintext on a remote profile are all refused rather than warned about. The\n password is generated, shown once and stored only as a hash. See\n [Share a service](../guides/sharing.md).\n- **Docker.** Its own socket proxy, not Traefik's, which stays read-only. It\n grants the read endpoints, container lifecycle and the four fixed exec\n endpoints used by the scoped console, and denies images, volumes, arbitrary\n exec, build, swarm, secrets, plugins and the system endpoints. The panel then\n refuses to emit any call outside its own allowlist, so `prune`, arbitrary\n `exec`, `archive` and `attach` are denied even where the proxy would forward\n them. See [ADR 0008](../../development/adr/0008-web-panel-socket-proxy.md).\n- **Blast radius.** A removal always sends `v=0&link=0`: volumes, networks and\n images outlive the container. The only container the panel can create is the\n socat TCP bridge, with a fixed image and no host access at all. Gateway\n components cannot be removed from it.\n\nA mutating request and every browser WebSocket upgrade must come from the\npanel's own origin, so a page on another site cannot drive it through\n`127.0.0.1`. `PORTTA_WEB_READ_ONLY=true` refuses every write, which is the\nright setting when an agent is driving it.\n\n### Applying settings, and what it costs\n\n`PORTTA_APPLY=true` is the one setting that widens the panel's reach past that\nfence, and it is off by default. With it, `portta up` prepares a stopped\ncontainer holding the Docker socket, whose command is fixed at creation, and the\npanel gains a button that starts it.\n\nSaid plainly: **anyone who can write through the panel can then run `portta up`\non this host, in a root container holding the socket** — which is root on the\nhost. The sharpest edge is `PORTTA_PROFILE`, which the Settings page can already\nwrite: saving `remote-public` and applying puts every opted-in service on the\ninternet with nobody at a terminal.\n\nWhat bounds it: it is off unless the operator edits `.env` on the host, and\n`PORTTA_APPLY` is deliberately absent from the panel's field catalogue, so the\npanel cannot enable itself. It is refused in read-only mode, refused when the\npanel is exposed publicly, and refused on the `remote-public` profile. The\napplier takes no argument from the panel, has no network, and the panel gains no\nnew Docker permission for it — `start` was already allowed, and\nThe server security tests fail the build if the proxy flags or the allowlist\ngrow. See [ADR 0026](../../development/adr/0026-applying-settings-from-the-panel.md).\n\nLeave it `false` on any host whose panel is reachable by someone you would not\nhand a shell.\n\n### Operating a project, and what it costs\n\n`PORTTA_RUNNER=true` is the second setting that widens the panel past the\nsocket-proxy fence, and it is off by default. With it, `portta up` prepares a\nstopped container holding the Docker socket and a view of the host filesystem\nat `/host`, whose command is fixed at creation (`scripts/lib/runner-exec.sh`).\nThe panel's part is to write `{ verb, project }` and start that container.\n\nSaid plainly: **anyone who can write through the panel can then run a closed\nset of Compose verbs against a project on this host**. The verbs are `up`,\n`stop`, `restart`, `build`, `down` and `down-volumes`. Adding one is an\nADR-level change.\n\nWhat bounds it: it is off unless the operator edits `.env` on the host, and\n`PORTTA_RUNNER` is deliberately absent from the panel's field catalogue. It is\nrefused in read-only mode, refused when the panel is exposed publicly, and\nrefused on the `remote-public` profile. The runner takes no command line from\nthe panel. The working directory comes from Docker's own labels, not from a\npath the request supplied. Rebuild is `build` (volumes preserved). Removal\nis `down` or `down-volumes`; directory removal is a flag on `down-volumes`\nonly, refused on a dirty tree unless overridden, and bounded to the\nresolved working directory. The project name is typed back and checked on\nthe server. See [ADR 0030](../../development/adr/0030-the-panel-and-a-project-lifecycle.md).\n\n## The audit log\n\nWho did what, to what, and from where. Written by the panel to its own\nPostgreSQL, read at **Settings → Audit** and at `GET /api/audit` with\n`audit:read` — which only `owner` and `admin` hold.\n\nWhat is recorded is a closed list, fixed in\n[ADR 0035](../../development/adr/0035-authentication-lives-in-the-panel.md) and in\n`packages/core/src/audit-actions.ts`: signing in, signing out and a failed\nsign-in; every change to an account, a role, a password, a ban, a session or a\nProject membership; tokens created and revoked; Projects created, updated and\ndeleted; an environment started, stopped, restarted, rebuilt, destroyed or\nforgotten; a service restarted; a container operated or removed; a bridge\nopened or closed; a share created or revoked; settings changed; the gateway\napplied; a GitHub installation appearing or going away; and a schema migration\nthat actually applied something.\n\nWhat is deliberately **not** recorded:\n\n- **Development work.** Tasks, work sessions, commits and notes are the work\n record and live in `activity_events`, which the Activity page reads. Mixing\n them in would bury the ten entries that matter under a thousand that do not.\n- **Reads.** Nobody's browsing is logged. The log answers \"who changed this\",\n not \"who looked at it\".\n- **Anything that authenticates.** No request body, no password, no hash, no\n token, no environment value. Each entry carries a small object the caller\n chose — a role, a list of setting *names*, a count — and a scrubber redacts a\n field named like a secret or a value shaped like one (`ptt_…`, a scrypt\n hash, a PEM header) before it is written. `packages/server/tests/audit*`\n passes a token through and asserts it does not come out.\n\nAn entry keeps the email of the account it is about, so it stays readable after\nthat account is deleted and its `user_id` goes null. Addresses come from\n`X-Forwarded-For`, which is the proxy's claim: it is recorded as such and used\nto decide nothing. Entries are pruned after 180 days by the hourly maintenance\njob.\n\n## Secrets\n\n- `.env` is git-ignored; `bootstrap` creates it `0600`; `doctor` warns if it\n becomes group- or world-readable.\n- `portta inspect` prints `<set>` / `<unset>`, never values.\n- Gateway state, including ACME material, lives under `state/`, which is\n git-ignored. `acme.json` is kept `0600` and `doctor` fails if it is not.\n- Lint fails the build on tracked Tailscale auth keys or PEM private keys.\n- The gateway never reads a consumer project's `.env` to \"helpfully\" print\n credentials. Connection strings it shows are templates unless the operator\n opens the connection panel, which reads the container's own environment\n for that one request.\n- The web panel's API never returns a secret value except\n `GET /api/access/services/:project/:service/connection`, which is the only\n route that may include a discovered password. The value is not cached, not\n persisted, not written to the panel's PostgreSQL, and not used as an OpenAPI\n example. A redaction helper strips it from anything that would be logged.\n Every other route reports whether a secret is set, and writing `.env` goes\n through a temporary file with mode `0600`.\n- Panel database clients run in the ephemeral toolbox on the private data\n network. Docker inherits `PGPASSWORD`; the credential is never interpolated\n into a connection URL or command argument. Dumps contain database objects,\n not the `.env` credential.\n\nFor Cloudflare, use a scoped API Token limited to `Zone:DNS:Edit` on one zone.\nNever the Global API Key: it authenticates everything in the account and cannot\nbe scoped or usefully rotated.\n\nFor Tailscale, prefer an ephemeral, tagged, pre-authorized auth key so a leaked\nkey ages out on its own.\n\n## Header aliasing\n\nA header named `X_Auth_User` becomes `X-Auth-User` once CGI, WSGI, PHP or nginx\nnormalises it, which lets a client forge a header Traefik believes it controls.\n`PORTTA_ALIAS_HEADERS_STRATEGY` selects `keep` (Traefik's default, fine\nbehind loopback), `delete` or `reject`. The public profile raises it to\n`delete`.\n\n## Shell safety\n\nThe CLI runs `set -euo pipefail`, parses `.env` rather than sourcing it (a\nbacktick in a value cannot execute), quotes expansions, and uses no `eval` on\nuser-supplied data. Project names and service names coming from Docker labels\nare normalised before being interpolated anywhere.\n\nThe container console is a stronger, separately granted boundary. Only owner\nand administrator hold `container:console`; developer, viewer and the default\nagent do not. The browser never supplies a command, user or privilege flag:\nthe server chooses `/bin/bash` or `/bin/sh` for a running service resolved from\nan environment and refuses Portta's own containers. Sessions have idle and\nmaximum limits, and opening and closing are audited without recording commands\nor output. See [Open a container console](../guides/container-console.md) and\n[ADR 0043](../../development/adr/0043-container-console-over-docker-exec.md).\n\n## What is not protected\n\nSettings → Environment includes a read-only Host security report for the SSH\nserver, host firewalls and Fail2ban. Its probes use only an explicit command\nallowlist, never `sudo`, and store only named configuration values — no host key\npaths, tokens, banned addresses or raw command output. Findings are contextual:\npassword authentication is a recommendation on a public server and neutral on\nan isolated workstation. A permissions failure is reported as indeterminate.\n\nThe report is guidance, not hardening automation. Portta never changes sshd, a\nfirewall, Fail2ban, a user or a port; see [Configure firewall rules](../guides/firewall.md)\nfor operator-controlled commands.\n\n- **Firewall.** Docker's published ports bypass UFW, so the bind address is\n the boundary the gateway actually relies on. See\n [Configure firewall rules](../guides/firewall.md).\n- **Project authorization and multi-user identity.** A project can opt one\n router into Portta's single-credential ForwardAuth with\n `portta-forward-auth@file`; Portta does not add roles, accounts or edit that\n project's labels. Use the VPN or a full IdP when that is the boundary needed.\n- **Multi-tenancy.** Every project on a host shares one Traefik and one shared\n network. This is a single-developer or single-team tool.\n- **Container escape.** The gateway reduces Docker API exposure; it does not\n harden the runtime itself.\n\n## Reporting\n\nFound something? Open a private security advisory on the repository rather than\na public issue.\n\n## Outbound network access\n\nUntil the GitHub App existed, the panel made exactly one outbound request, to\nTraefik's API on an internal address. With `GITHUB_APP_ENABLED=true` it also\ntalks to `api.github.com` (or your Enterprise Server root).\n\nThis is worth saying plainly on a VPS: **a panel that may be routed over a VPN\nnow reaches the internet.** It does so on the `gateway` network it is already\nattached to; `webcontrol` and `webdata` stay `internal: true`, so neither the\nDocker socket proxy nor the database gains a route out.\n\nThe panel holds one long-lived secret for this — the App's private key — and it\nholds it as a **file it cannot write**: a `.pem` under `state/github/`, mounted\nread-only at mode 600, passed by path rather than as a `.env` value precisely\nbecause the panel can write `.env`. That directory is the only route the key\nhas into the container, so it is also the only path the panel accepts.\n`portta doctor` fails, rather than warns, on a key that is missing, unreadable,\nreadable by more than its owner, or outside that directory.\n\nInstallation tokens live for an hour in memory and are never persisted. No\ntoken, key or webhook secret appears in any API response, and tests assert it.\n\nWith the integration off — the default — none of the above applies: the panel\nmakes no request to github.com at all. See [Connect GitHub](../guides/github.md).\n","text":"Security Threat model The gateway is a development tool. Its job is to make it hard to exposesomething by accident, and to keep an accident's blast radius small. The realistic risks are, in order: Accidental exposure. A database on 0.0.0.0, a dashboard on a publicinterface, a \"temporary\" public domain nobody turned off. Docker socket access. The API is not namespaced; reaching it means rooton the host. Secret leakage. Auth keys and API tokens in Git, logs, or shell history. Lateral movement. One project's compromise reaching another's database. Exposure depends on the selected mode providers.docker.exposedByDefault=false. A service is routed only when itsets traefik.enable=true. The local profile binds Traefik to 127.0.0.1. doctor fails if thelocal profile is bound anywhere else. The public profile is off, and turning it on prints what will becomereachable and asks for confirmation. Databases and caches are never published and never joined to the sharednetwork. doctor fails on a datastore published on 0.0.0.0 and warns onone attached to the shared network. The panel's own PostgreSQL is stricter: it publishes no port at all, joinsonly an internal data network, and makes doctor fail if it appears on theshared HTTP network. The Docker socket Traefik never sees it. Discovery goes throughtecnativa/docker-socket-proxy, which mounts the socket read-only andallows only CONTAINERS, NETWORKS, EVENTS, PING and VERSION. Allwrites are denied (POST=0). The proxy runs read_only: true, publishes nohost port, and lives alone with Traefik on a network created internal: true. doctor fails if the socket is mounted into Traefik, if the proxy's mount iswritable, if the proxy publishes a host port, or if the control network is notinternal. Residual risk, stated plainly. Discovery requiresGET /containers/{id}/json, whose response includes container environmentvariables. A compromised Traefik could therefore read secrets that consumerprojects pass as environment variables. This is inherent to Traefik's Dockerprovider, not to this proxy. If that matters for a given project, pass secretsas files or via a secrets manager rather than env vars. See ADR 0002. Network isolation Each project keeps its own private network. Postgres, Redis, queues and searchstay there. Traefik has no route to those networks, and neither does any otherproject. tests/e2e/parallel.test.sh asserts that one project cannot reachanother's database. The shared portta network is the one place projects meet, and onlyHTTP-facing services join it. Anything on it is reachable by every otherproject on the host, which is exactly why a database does not belong there. TCP access bridges A bridge is a hole into a project's private network, so it is kept small andshort-lived. It binds 127.0.0.1 on a kernel-assigned port; binding anywhereelse needs an explicit --bind and a confirmation, and doctor fails on abridge bound beyond loopback. portta service publish --public on a datastore is refused outright, notwarned about. Persistent forwarders join their project's network and thegateway's access network only, never the shared HTTP network, which doctoralso enforces. access close and access gc re-check the ownership label on the code paththat actually removes a container, rather than trusting the filter that foundit. The dashboard Off by default. When enabled it is published on its own loopback-bound port andattached only to Traefik's internal entrypoint, so it is never routed throughweb/websecure and cannot appear under a public wildcard domain. doctorfails if it is enabled on a non-loopback address. The loopback bind constrains the host, not the shared network. Insecure modelistens inside a namespace attached to portta, so while the dashboard isenabled any adopted project's container can reach http://traefik:8080 andread the full routing configuration, including the hostnames and backends ofevery other project on the host. On the Tailscale attachment the same APIanswers at http://tailscale:8080. That is the cost of turning it on, it iswhy it is off by default, and it is the same API the panel reads for a router'sstatus. Nothingsensitive to a project's own users is there, but the inventory of the host is. The dashboard has no routed access mode. doctor fails a non-loopbackPORTTA_DASHBOARD_BIND_ADDRESS. Databases reached by hostname Off by default. Turning it on publishes one port per protocol; it does notpublish a database. Three things have to line up before one answers:PORTTA_TCP=true on the gateway, traefik.enable=true plus TCP routerlabels on the container, and the container on the access network. Being visibleto the gateway is not being routed. Never public. The TCP entrypoints are refused on the remote-publicprofile, where Traefik binds every interface. That is a refusal at profileresolution, not a warning, and doctor fails if the combination is everreached another way. Where they listen follows the profile, like everything else: loopbacklocally, the tailnet address with Tailscale, an interface you namedotherwise. Not on the HTTP network. An opted-in datastore joinsportta-access. The shared network still carries no database; Composevalidation and routing tests enforce that boundary. TLS is mandatory, since the hostname lives in the handshake. Without aconfigured certificate Traefik serves a self-signed one, whichsslmode=require accepts and verify-full does not; doctor says so. Authentication is unchanged and is still the database's own. The gateway routesbytes and never reads a project's credentials. The web panel The base gateway can run without the panel. The installer enables the panel and defaults to public access with authentication required. The panel can start, stop and remove containers, so its access mode must be chosen deliberately. Network. The local mode binds loopback. VPN routing and the dedicated public panelentrypoint are separate, explicit overlays; the public overlay does notpublish the application's web/websecure entrypoints. Authentication, which the panel does itself. Every access mode but local— tailscale, public, vpn, domain — requires PORTTA_AUTH_MODE=requiredand is refused without it, by portta web up, by portta up, and by thepanel's own process at boot. Thepanel signs people in against its own database: a session cookie for a person,a ptt_ Bearer token for a CLI or an agent, and a role that decides what eachmay do. Every operation declares the permission it needs, 401 and 403 meandifferent things, and a revoked token or a banned user stops working on thenext request rather than the next sign-in. PORTTA_AUTH_MODE=disabled answerseverybody as the local operator and is refused anywhere but loopback. A routedpanel also defaults to read-only, and doctor fails if either is missing. SeeConfigure authentication andADR 0035. Live channels. The event stream needs activity:read and filters everyevent against the principal that opened it; an event about a Project somebodydoes not reach is never delivered, and an event about no Project at all goesonly to scope: 'all'. The log WebSocket is authorised before thehandshake becomes a socket — logs:read, scoped to the Project that adoptedthe environment — and a refusal is answered as HTTP and then closed, neverleft hanging. One upgrade listener owns every /ws/… path, including theones it refuses. Query parameters are validated before anything uses them,and the stream comes from the Docker API through the panel's own socketproxy: nothing is concatenated into a command. Traefik configuration. The panel may write three filenames inconfig/traefik/dynamic/ and refuses every other path in its own process.See ADR 0011. Temporary shares. A share is one additional hostname for one service,with a mandatory expiry, on a network the gateway already answers: it exposesa hostname, never a network. Sharing a datastore, a service off the sharednetwork, anything public without PUBLIC_ENABLED, and a password overplaintext on a remote profile are all refused rather than warned about. Thepassword is generated, shown once and stored only as a hash. SeeShare a service. Docker. Its own socket proxy, not Traefik's, which stays read-only. Itgrants the read endpoints, container lifecycle and the four fixed execendpoints used by the scoped console, and denies images, volumes, arbitraryexec, build, swarm, secrets, plugins and the system endpoints. The panel thenrefuses to emit any call outside its own allowlist, so prune, arbitraryexec, archive and attach are denied even where the proxy would forwardthem. See ADR 0008. Blast radius. A removal always sends v=0&link=0: volumes, networks andimages outlive the container. The only container the panel can create is thesocat TCP bridge, with a fixed image and no host access at all. Gatewaycomponents cannot be removed from it. A mutating request and every browser WebSocket upgrade must come from thepanel's own origin, so a page on another site cannot drive it through127.0.0.1. PORTTA_WEB_READ_ONLY=true refuses every write, which is theright setting when an agent is driving it. Applying settings, and what it costs PORTTA_APPLY=true is the one setting that widens the panel's reach past thatfence, and it is off by default. With it, portta up prepares a stoppedcontainer holding the Docker socket, whose command is fixed at creation, and thepanel gains a button that starts it. Said plainly: anyone who can write through the panel can then run portta upon this host, in a root container holding the socket — which is root on thehost. The sharpest edge is PORTTA_PROFILE, which the Settings page can alreadywrite: saving remote-public and applying puts every opted-in service on theinternet with nobody at a terminal. What bounds it: it is off unless the operator edits .env on the host, andPORTTA_APPLY is deliberately absent from the panel's field catalogue, so thepanel cannot enable itself. It is refused in read-only mode, refused when thepanel is exposed publicly, and refused on the remote-public profile. Theapplier takes no argument from the panel, has no network, and the panel gains nonew Docker permission for it — start was already allowed, andThe server security tests fail the build if the proxy flags or the allowlistgrow. See ADR 0026. Leave it false on any host whose panel is reachable by someone you would nothand a shell. Operating a project, and what it costs PORTTA_RUNNER=true is the second setting that widens the panel past thesocket-proxy fence, and it is off by default. With it, portta up prepares astopped container holding the Docker socket and a view of the host filesystemat /host, whose command is fixed at creation (scripts/lib/runner-exec.sh).The panel's part is to write { verb, project } and start that container. Said plainly: anyone who can write through the panel can then run a closedset of Compose verbs against a project on this host. The verbs are up,stop, restart, build, down and down-volumes. Adding one is anADR-level change. What bounds it: it is off unless the operator edits .env on the host, andPORTTA_RUNNER is deliberately absent from the panel's field catalogue. It isrefused in read-only mode, refused when the panel is exposed publicly, andrefused on the remote-public profile. The runner takes no command line fromthe panel. The working directory comes from Docker's own labels, not from apath the request supplied. Rebuild is build (volumes preserved). Removalis down or down-volumes; directory removal is a flag on down-volumesonly, refused on a dirty tree unless overridden, and bounded to theresolved working directory. The project name is typed back and checked onthe server. See ADR 0030. The audit log Who did what, to what, and from where. Written by the panel to its ownPostgreSQL, read at Settings → Audit and at GET /api/audit withaudit:read — which only owner and admin hold. What is recorded is a closed list, fixed inADR 0035 and inpackages/core/src/audit-actions.ts: signing in, signing out and a failedsign-in; every change to an account, a role, a password, a ban, a session or aProject membership; tokens created and revoked; Projects created, updated anddeleted; an environment started, stopped, restarted, rebuilt, destroyed orforgotten; a service restarted; a container operated or removed; a bridgeopened or closed; a share created or revoked; settings changed; the gatewayapplied; a GitHub installation appearing or going away; and a schema migrationthat actually applied something. What is deliberately not recorded: Development work. Tasks, work sessions, commits and notes are the workrecord and live in activity_events, which the Activity page reads. Mixingthem in would bury the ten entries that matter under a thousand that do not. Reads. Nobody's browsing is logged. The log answers \"who changed this\",not \"who looked at it\". Anything that authenticates. No request body, no password, no hash, notoken, no environment value. Each entry carries a small object the callerchose — a role, a list of setting names, a count — and a scrubber redacts afield named like a secret or a value shaped like one (ptt_…, a scrypthash, a PEM header) before it is written. packages/server/tests/audit*passes a token through and asserts it does not come out. An entry keeps the email of the account it is about, so it stays readable afterthat account is deleted and its user_id goes null. Addresses come fromX-Forwarded-For, which is the proxy's claim: it is recorded as such and usedto decide nothing. Entries are pruned after 180 days by the hourly maintenancejob. Secrets .env is git-ignored; bootstrap creates it 0600; doctor warns if itbecomes group- or world-readable. portta inspect prints <set> / <unset>, never values. Gateway state, including ACME material, lives under state/, which isgit-ignored. acme.json is kept 0600 and doctor fails if it is not. Lint fails the build on tracked Tailscale auth keys or PEM private keys. The gateway never reads a consumer project's .env to \"helpfully\" printcredentials. Connection strings it shows are templates unless the operatoropens the connection panel, which reads the container's own environmentfor that one request. The web panel's API never returns a secret value exceptGET /api/access/services/:project/:service/connection, which is the onlyroute that may include a discovered password. The value is not cached, notpersisted, not written to the panel's PostgreSQL, and not used as an OpenAPIexample. A redaction helper strips it from anything that would be logged.Every other route reports whether a secret is set, and writing .env goesthrough a temporary file with mode 0600. Panel database clients run in the ephemeral toolbox on the private datanetwork. Docker inherits PGPASSWORD; the credential is never interpolatedinto a connection URL or command argument. Dumps contain database objects,not the .env credential. For Cloudflare, use a scoped API Token limited to Zone:DNS:Edit on one zone.Never the Global API Key: it authenticates everything in the account and cannotbe scoped or usefully rotated. For Tailscale, prefer an ephemeral, tagged, pre-authorized auth key so a leakedkey ages out on its own. Header aliasing A header named X_Auth_User becomes X-Auth-User once CGI, WSGI, PHP or nginxnormalises it, which lets a client forge a header Traefik believes it controls.PORTTA_ALIAS_HEADERS_STRATEGY selects keep (Traefik's default, finebehind loopback), delete or reject. The public profile raises it todelete. Shell safety The CLI runs set -euo pipefail, parses .env rather than sourcing it (abacktick in a value cannot execute), quotes expansions, and uses no eval onuser-supplied data. Project names and service names coming from Docker labelsare normalised before being interpolated anywhere. The container console is a stronger, separately granted boundary. Only ownerand administrator hold container:console; developer, viewer and the defaultagent do not. The browser never supplies a command, user or privilege flag:the server chooses /bin/bash or /bin/sh for a running service resolved froman environment and refuses Portta's own containers. Sessions have idle andmaximum limits, and opening and closing are audited without recording commandsor output. See Open a container console andADR 0043. What is not protected Settings → Environment includes a read-only Host security report for the SSHserver, host firewalls and Fail2ban. Its probes use only an explicit commandallowlist, never sudo, and store only named configuration values — no host keypaths, tokens, banned addresses or raw command output. Findings are contextual:password authentication is a recommendation on a public server and neutral onan isolated workstation. A permissions failure is reported as indeterminate. The report is guidance, not hardening automation. Portta never changes sshd, afirewall, Fail2ban, a user or a port; see Configure firewall rulesfor operator-controlled commands. Firewall. Docker's published ports bypass UFW, so the bind address isthe boundary the gateway actually relies on. SeeConfigure firewall rules. Project authorization and multi-user identity. A project can opt onerouter into Portta's single-credential ForwardAuth withportta-forward-auth@file; Portta does not add roles, accounts or edit thatproject's labels. Use the VPN or a full IdP when that is the boundary needed. Multi-tenancy. Every project on a host shares one Traefik and one sharednetwork. This is a single-developer or single-team tool. Container escape. The gateway reduces Docker API exposure; it does notharden the runtime itself. Reporting Found something? Open a private security advisory on the repository rather thana public issue. Outbound network access Until the GitHub App existed, the panel made exactly one outbound request, toTraefik's API on an internal address. With GITHUB_APP_ENABLED=true it alsotalks to api.github.com (or your Enterprise Server root). This is worth saying plainly on a VPS: a panel that may be routed over a VPNnow reaches the internet. It does so on the gateway network it is alreadyattached to; webcontrol and webdata stay internal: true, so neither theDocker socket proxy nor the database gains a route out. The panel holds one long-lived secret for this — the App's private key — and itholds it as a file it cannot write: a .pem under state/github/, mountedread-only at mode 600, passed by path rather than as a .env value preciselybecause the panel can write .env. That directory is the only route the keyhas into the container, so it is also the only path the panel accepts.portta doctor fails, rather than warns, on a key that is missing, unreadable,readable by more than its owner, or outside that directory. Installation tokens live for an hour in memory and are never persisted. Notoken, key or webhook secret appears in any API response, and tests assert it. With the integration off — the default — none of the above applies: the panelmakes no request to github.com at all. See Connect GitHub.","headings":[{"id":"security","text":"Security","level":1,"line":0},{"id":"threat-model","text":"Threat model","level":2,"line":2},{"id":"exposure-depends-on-the-selected-mode","text":"Exposure depends on the selected mode","level":2,"line":16},{"id":"the-docker-socket","text":"The Docker socket","level":2,"line":31},{"id":"network-isolation","text":"Network isolation","level":2,"line":52},{"id":"tcp-access-bridges","text":"TCP access bridges","level":2,"line":63},{"id":"the-dashboard","text":"The dashboard","level":2,"line":79},{"id":"databases-reached-by-hostname","text":"Databases reached by hostname","level":2,"line":99},{"id":"the-web-panel","text":"The web panel","level":2,"line":124},{"id":"applying-settings-and-what-it-costs","text":"Applying settings, and what it costs","level":3,"line":181},{"id":"operating-a-project-and-what-it-costs","text":"Operating a project, and what it costs","level":3,"line":206},{"id":"the-audit-log","text":"The audit log","level":2,"line":230},{"id":"secrets","text":"Secrets","level":2,"line":267},{"id":"header-aliasing","text":"Header aliasing","level":2,"line":298},{"id":"shell-safety","text":"Shell safety","level":2,"line":306},{"id":"what-is-not-protected","text":"What is not protected","level":2,"line":322},{"id":"reporting","text":"Reporting","level":2,"line":347},{"id":"outbound-network-access","text":"Outbound network access","level":2,"line":352}],"kind":"markdown"},{"slug":"github-sync","title":"GitHub synchronization","description":"Understand how local tasks and GitHub issues synchronize.","source":"docs/product/concepts/github-sync.md","audience":"user","section":"Concepts","category":"","url":"/docs/github-sync","markdown":"# GitHub synchronization\n\nPortta keeps local tasks and a projection of GitHub issues.\n\n## The issue and the environment it is worked in\n\nThis is the join the rest of the sequence exists for. GitHub knows `#182` is\n*In Progress* on branch `fix/182-tcp-proxy`. Only Portta knows that\nbranch is running as `base-empresarial-issue182`, with `web` and `api` on\n`web.issue-182.localhost`, and what its logs say.\n\n```text\n#182 Proxy TCP perde conexão\nBug · Priority: High · Status: In Progress\nBranch: fix/182-tcp-proxy · Environment: base-empresarial-issue182 (running)\nweb.issue-182.localhost api.issue-182.localhost\n```\n\n**Linking writes one row.** It never starts, stops, creates or removes\nanything.\n\n### Inferred, then corrected, with the reason recorded\n\nIn order, first match wins:\n\n| Source | Meaning |\n|---|---|\n| `manual` | You linked them in the panel. Always wins |\n| `label` | The environment declares `portta.task` with the Portta task id |\n| `branch` | The branch contains the Portta task coordinate |\n| `namespace` | The Compose project or worktree contains the Portta task coordinate |\n\nEach rule is a pure function over data the panel already has — no Docker call,\nno GitHub call — so the UI can say *\"linked because this environment is on\nbranch `fix/182-tcp-proxy`\"* rather than presenting a mysterious association. A\ncoordinate that matches two projected issues links nothing and offers the\nchoice.\n\nOne task may have several environments; an environment belongs to at most one\ntask. A GitHub issue appears with those environments when it is bound to that\ntask.\n\n### Where it shows\n\n- **On the issue**, an Environments section: each linked environment with its\n running count, its branch, its endpoints and a link straight into the project\n page's Logs tab. A linked environment that is not running says how to start it\n rather than showing an error — the panel never starts one for you.\n- **On the environment**, a compact issue block on the project page's Overview\n tab: repository, number, title, type, priority, status, the reason for the\n link, and links to GitHub and to the panel.\n\n`GET /api/projects/:project` gains a nullable `issue` block, so nothing that\nread it before breaks. `GET /api/issues/:id` gains `environments`, and\n`PUT /api/issues/:id/environments` is the manual link.\n\n### What is deliberately not built\n\nThere is no `agent_runs` table. Nothing would write to it, and this project\npersists decisions rather than speculation. Adding one later is\n`CREATE TABLE agent_runs (… issue_id, project_id …)` and no change to anything\nabove; `issue_environments.worktree_path` is reserved for the same reason and is\nnull today.\n\n## What stays on the host\n\n`portta repos scan` still collects branch, HEAD, dirty counts and\nahead/behind from local `git`, and writes one `state/git/<key>.json` per\nrepository plus an index that maps each environment to its repository.\nThe panel only reads that snapshot. No project directory is mounted into\nthe panel. See [ADR 0010](../../development/adr/0010-git-collected-on-the-host.md).\n\n\n\n## Panel responsibilities\n\nA GitHub App, once configured, reads and writes issues, sub-issues, issue\ntypes, issue fields and pull-request state. Local working trees stay\nread-only. A Personal Access Token in `.env` is not the design.\n\n\n\n## Source of truth\n\n| Fact | Owner |\n|---|---|\n| Portta Task title, description, status, priority, labels and assignee | Portta. These fields can be copied explicitly across a GitHub binding; the local Task remains usable and authoritative when GitHub is unavailable |\n| Agent, due date, parent, repository/environment/service, board rank and local comments | Portta only |\n| GitHub issue state, milestone, field values, sub-issue links and pull-request state | GitHub, held as an external projection |\n| Branch, HEAD, dirty counts, ahead/behind | Local `git` on the host |\n| Containers, health, URLs, networks | Docker / Traefik on this host |\n| Which GitHub repositories a Portta project owns | Portta |\n| Which environments a Portta project has adopted | Portta |\n| A link from an issue to an environment | Portta |\n\nThe panel never treats a Task as a cache of GitHub. A local write commits\nfirst. When a binding exists, the shared fields are then pushed; an unavailable\nGitHub leaves the binding `pending` or `error` and never rolls the Task back.\nLinking an existing issue also requires an explicit first direction (`pull` or\n`push`), so neither side silently wins. See [ADR 0033](../../development/adr/0033-tasks-are-local-issues.md).\n\n\n\n## Projects: repositories and the environments that belong to them\n\nA **Project** is the grouping a person creates. It owns repositories, adopts\nenvironments, carries the board and — unlike an environment — does not\ndisappear when nothing is running. That is why it is persisted rather than\nderived.\n\n```text\nProject \"Meu Produto\"\n├── repositories api (local clone, bound to acme/produto-api) · web (acme/produto-web)\n└── environments produto (path)\n produto-issue182 (repo-match)\n```\n\nA repository belongs to exactly one Project, and a monorepo is one\nrepository in one Project. `role` is free text with a documented vocabulary —\n`api`, `web`, `mobile`, `services`, `infra`, `docs`, `other` — so adding one\nlater is not a migration. A repository exists without GitHub: a path under\nProjects Home, or a remote, is enough. Binding it to a GitHub repository the\nApp was granted is what makes issues, pull-request state and the write-back\navailable for it.\n\n### How an environment is adopted, and why\n\nIn order, first match wins:\n\n| Source | Meaning |\n|---|---|\n| `manual` | You linked them in the panel. Always wins |\n| `label` | The environment carries `portta.project: <slug>`. The project declared it, per ADR 0001 |\n| `repo-match` | The environment's remote matches a repository this Project owns — applied **only when exactly one Project owns that coordinate** |\n| `path` | The environment's working directory sits under the Project's directory, or under one of its repositories, and no other Project claims it |\n\nThe source is stored and shown, so the panel says *\"adopted because it carries\n`portta.project: meu-produto`\"* rather than presenting a mapping with no\nexplanation. An ambiguous match adopts nothing and leaves the choice to you:\nan automatic adoption that is wrong is worse than none.\n\nAn environment belongs to at most one Project; a Project may have any number,\nincluding none.\n\n### What the API keeps separate\n\n`GET /api/projects` lists the product (the grouping). `GET /api/environments`\nlists Compose stacks observed on this host.\n\n`DELETE /api/projects/:slug` removes **the grouping and what only Portta\nholds about it** — its repositories, tasks, sessions and activity rows: no\ncontainer is stopped, no volume is removed, no environment is changed, and no\nrepository is unlinked from GitHub. The response says so, because it is the\nendpoint most likely to be misread.\n\nEvery Project endpoint needs the panel's database and answers `503` with a\nhint when it is unavailable; writes are refused in read-only mode.\n\n\n\n## Project, environment, repository\n\nA **project** is a grouping a person creates. An **environment** is one\nCompose project on this host (`COMPOSE_PROJECT_NAME`). A **repository**\nis a GitHub repository bound to a project. Today's `projects` table is\nthe environment; renaming it is part of building the new project entity.\n\nThe Compose label `portta.project` remains a hint for grouping\nworktrees. It does not silently create a Portta project.\n\n\n\n## What is stored, and what is not\n\nStored, in the panel's own PostgreSQL:\n\n- `github_installations` — which installations exist, for which account, with\n which permissions, and when they were last seen.\n- `github_repositories` — the repositories those installations granted. This\n table is the authorisation boundary.\n- `github_sync_state` — one row per sync scope, with its last run and its last\n error, so a failure is visible rather than silent.\n\nEvery row carries `synced_at`, so the UI can always say how old an answer is —\nthe same discipline the repository scan already applies.\n\n**Never stored:** the private key, the webhook secret, and any installation\ntoken. A token lives for an hour, is minted on demand, cached in memory with\nits expiry and refreshed early. No code path writes one to a row, a log line or\nan API response, and a test asserts that.\n\n\n\n## Issues, and how they stay in step\n\nIssues are **projected**: GitHub owns them, and the panel keeps a local copy so\nthe board answers while GitHub is unreachable. Every row carries `syncedAt` and\na staleness flag, exactly as `ProjectGit` carries `collectedAt` and `stale`.\n\nGitHub comments are deliberately not projected wholesale. They are large, they change often, and\na link to GitHub beats a partial mirror — the same reasoning\n[ADR 0010](../../development/adr/0010-git-collected-on-the-host.md) used for commit lists. There\nis no `github_issue_comments` table and a test asserts there is not.\n\nPortta comments are local entities. A user may explicitly publish one as a\ncopy to the bound GitHub issue. Portta records the returned comment id and URL,\nand a failed publication remains retryable without losing the local comment.\n\n### Issues and tasks\n\nA **Task** is Portta's own unit of work; it exists without GitHub. An issue\non a repository a Project owns becomes a task bound to it — every existing\nissue did so in the migration that introduced tasks, and a new one does on\nthe next reconciliation. The board, `portta tasks` and `portta mcp` all work\non tasks; `owner/repo#number` still addresses a bound one.\n\nA write to a bound task is persisted in Portta first and then pushed. When the\nApp is unavailable the local write succeeds and the binding is marked\n`pending` or `error` until `POST /api/tasks/:ref/github/sync` retries it. A remote\nchange that lands on a pending local edit is a `conflict`, kept and shown\nwith both sides and the names of the shared fields that differ. `sync` with\n`resolve: local | remote` settles the whole conflict; `resolve: fields` plus a\nfield list takes those values from GitHub, keeps the remaining local shared\nvalues and pushes the merge. Comments, parent, agent, type, service, due date\nand draft stay local and never appear as conflicts; a draft is not published\nuntil it has a real title. See [Tasks](../guides/tasks.md) and\n[ADR 0033](../../development/adr/0033-tasks-are-local-issues.md).\n\n### Status and priority: fields where they exist, labels where they do not\n\nNot every account has GitHub's native issue types and project fields, so status\nand priority are read through one abstraction with two implementations. A\nnative field wins where the repository has one; otherwise a documented label\nconvention decides:\n\n| Status | Label |\n|---|---|\n| Backlog | `status:backlog` |\n| Ready | `status:ready` |\n| In Progress | `status:in-progress` |\n| Review | `status:review` |\n| Blocked | `status:blocked` |\n| Done | `status:done` |\n\n| Priority | Label |\n|---|---|\n| Low | `priority:low` |\n| Medium | `priority:medium` |\n| High | `priority:high` |\n| Urgent | `priority:urgent` |\n\nNo caller knows which mechanism was used — but **every response says which**,\nin `metadataSource`, because it changes what a write does. Setting a status\nthrough labels means adding one label and removing another, and that shows in\nthe issue's timeline. The panel marks a label-derived status so nobody is\nsurprised by it.\n\nOnly the dimension being changed is cleared, so setting a priority never\nsilently drops a status.\n\nProject workflows do not change this mapping. They only decide which catalog\nids a person may write inside that Project. A hidden status is simply unused\nthere; an already-bound issue or local task that still carries it remains\nreadable, and all six ids retain one stable GitHub representation.\n\n### What this does not read: GitHub Projects v2\n\n`metadataSource` is `fields`, `labels` or `none`. Projects v2 fields are\n**GraphQL-only**, and Portta has no GraphQL client, so a repository whose board\nlives in a Project is invisible here — and worse, Portta's `status:*` label\nwrites will not move its cards, which is exactly the second source of truth\n[ADR 0018](../../development/adr/0018-github-access-lives-in-the-panel.md) exists to forbid.\n\nThe seam for it is deliberate: `project` would be a fourth `MetadataSource`,\nadded *only when a real repository demands it*, together with the GraphQL client\nit needs. Recorded as an extension point in ADR 0018's 2026-09-02 amendment, and\nnot a plan. Sub-issues and issue types are REST, and already in use.\n\n### Three sync paths\n\n| Path | When | How |\n|---|---|---|\n| **Initial** | A repository is newly authorised | Page through its issues, project them, then resolve sub-issue links in a second pass so a child seen before its parent is not lost |\n| **Reconciliation** | On demand, and every `GITHUB_SYNC_INTERVAL_MINUTES` (default 15; `0` turns the timer off) | Ask only for issues updated since the stored cursor. Bounded per run; rate-limit pressure ends the run rather than failing it, and the next run resumes from the cursor. A tick that arrives while the previous pass is still running is skipped |\n| **Webhook** | A delivery arrives | A signal to re-read, never data to trust |\n\n`POST /api/integrations/github/sync` runs the repository sync and a\nreconciliation pass: one button, one meaning. The timer calls the same\n`reconcile`, so pressing the button is asking for the next pass now rather than\nfor something different.\n\nThe timer is what makes a **loopback panel** correct rather than merely\npossible: it cannot receive a webhook delivery, so without it the projection is\nonly as fresh as the last time somebody pressed Sync. Turn it off with\n`GITHUB_SYNC_INTERVAL_MINUTES=0` on a panel that does receive deliveries, where\nit would otherwise do the same work twice.\n\n### The webhook, and the hole it is allowed to make\n\nThe panel refuses every unsafe method without a same-origin `Origin` header.\nGitHub sends none, so `POST /api/integrations/github/webhook` is exempt from\nthat guard — **narrowly, by exact path, and only because an HMAC signature over\nthe raw body replaces it**.\n\nThe signature is verified *before* the body is parsed as anything meaningful. An\ninvalid one is a `401` that logs the delivery id and nothing else. Read-only\nmode still refuses the route, and a delivery for a repository the installation\nnever granted changes nothing: the projection is the boundary, and deliveries do\nnot widen it.\n\nHandled events: `issues`, `label`, `milestone`, `sub_issues`, `pull_request`,\n`repository`, `installation`, `installation_repositories`. Anything else —\nincluding `issue_comment` — is acknowledged and dropped, and an unhandled event\nis not an error.\n\nWebhooks stay optional. A loopback panel cannot receive them, and correctness\ncomes from reconciliation — which now runs on a timer as well as on demand, so\n\"correctness comes from reconciliation\" is a statement about what the panel does\nrather than about what it could do. They are an optimisation for a panel you\nhave already published.\n\n### Sub-issues\n\nSub-issue links come from GitHub's own API and are stored as a graph that\ncannot cycle: the database refuses `a → a`, and a longer path is refused by\nwalking the graph before the row is written. A link whose parent is in a\nrepository the installation did not grant is dropped rather than dangling, so\nthe tree the UI renders always terminates.\n\n### Writes go through GitHub\n\n`PATCH /api/issues/:id` writes to GitHub and then updates the projection **from\nwhat GitHub returned** — never from what was requested. The panel never shows an\nissue GitHub did not confirm. It is refused in read-only mode, refused when no\nApp is configured, and refused for a repository outside the installation.\n\n### What a status change actually does\n\n| Provider | Moving a card to *Done* |\n|---|---|\n| Native fields | Sets the field. Nothing appears in the issue's timeline |\n| Labels | Adds `status:done` and removes the previous `status:` label. **Both show in the timeline** |\n\nThe panel marks a label-derived status so the difference is visible before you\nmove anything, and only the dimension being changed is cleared: setting a\npriority never silently drops a status.\n\n### Pull requests: one source, stated\n\nThe host `gh` scan and the App can both report open pull requests. **When the\nApp is configured and the repository is authorised, the App wins**; otherwise\nthe scan's `forge` block stands exactly as it does today. A panel with no App\nsees `GET /api/projects/:project/git` behave precisely as before.\n\n\nSee [GitHub synchronization](github-sync.md) for the data flow.\n","text":"GitHub synchronization Portta keeps local tasks and a projection of GitHub issues. The issue and the environment it is worked in This is the join the rest of the sequence exists for. GitHub knows #182 isIn Progress on branch fix/182-tcp-proxy. Only Portta knows thatbranch is running as base-empresarial-issue182, with web and api onweb.issue-182.localhost, and what its logs say. Linking writes one row. It never starts, stops, creates or removesanything. Inferred, then corrected, with the reason recorded In order, first match wins: Source Meaning manual You linked them in the panel. Always wins label The environment declares portta.task with the Portta task id branch The branch contains the Portta task coordinate namespace The Compose project or worktree contains the Portta task coordinate Each rule is a pure function over data the panel already has — no Docker call,no GitHub call — so the UI can say \"linked because this environment is onbranch fix/182-tcp-proxy\" rather than presenting a mysterious association. Acoordinate that matches two projected issues links nothing and offers thechoice. One task may have several environments; an environment belongs to at most onetask. A GitHub issue appears with those environments when it is bound to thattask. Where it shows On the issue, an Environments section: each linked environment with itsrunning count, its branch, its endpoints and a link straight into the projectpage's Logs tab. A linked environment that is not running says how to start itrather than showing an error — the panel never starts one for you. On the environment, a compact issue block on the project page's Overviewtab: repository, number, title, type, priority, status, the reason for thelink, and links to GitHub and to the panel. GET /api/projects/:project gains a nullable issue block, so nothing thatread it before breaks. GET /api/issues/:id gains environments, andPUT /api/issues/:id/environments is the manual link. What is deliberately not built There is no agent_runs table. Nothing would write to it, and this projectpersists decisions rather than speculation. Adding one later isCREATE TABLE agent_runs (… issue_id, project_id …) and no change to anythingabove; issue_environments.worktree_path is reserved for the same reason and isnull today. What stays on the host portta repos scan still collects branch, HEAD, dirty counts andahead/behind from local git, and writes one state/git/<key>.json perrepository plus an index that maps each environment to its repository.The panel only reads that snapshot. No project directory is mounted intothe panel. See ADR 0010. Panel responsibilities A GitHub App, once configured, reads and writes issues, sub-issues, issuetypes, issue fields and pull-request state. Local working trees stayread-only. A Personal Access Token in .env is not the design. Source of truth Fact Owner Portta Task title, description, status, priority, labels and assignee Portta. These fields can be copied explicitly across a GitHub binding; the local Task remains usable and authoritative when GitHub is unavailable Agent, due date, parent, repository/environment/service, board rank and local comments Portta only GitHub issue state, milestone, field values, sub-issue links and pull-request state GitHub, held as an external projection Branch, HEAD, dirty counts, ahead/behind Local git on the host Containers, health, URLs, networks Docker / Traefik on this host Which GitHub repositories a Portta project owns Portta Which environments a Portta project has adopted Portta A link from an issue to an environment Portta The panel never treats a Task as a cache of GitHub. A local write commitsfirst. When a binding exists, the shared fields are then pushed; an unavailableGitHub leaves the binding pending or error and never rolls the Task back.Linking an existing issue also requires an explicit first direction (pull orpush), so neither side silently wins. See ADR 0033. Projects: repositories and the environments that belong to them A Project is the grouping a person creates. It owns repositories, adoptsenvironments, carries the board and — unlike an environment — does notdisappear when nothing is running. That is why it is persisted rather thanderived. A repository belongs to exactly one Project, and a monorepo is onerepository in one Project. role is free text with a documented vocabulary —api, web, mobile, services, infra, docs, other — so adding onelater is not a migration. A repository exists without GitHub: a path underProjects Home, or a remote, is enough. Binding it to a GitHub repository theApp was granted is what makes issues, pull-request state and the write-backavailable for it. How an environment is adopted, and why In order, first match wins: Source Meaning manual You linked them in the panel. Always wins label The environment carries portta.project: <slug>. The project declared it, per ADR 0001 repo-match The environment's remote matches a repository this Project owns — applied only when exactly one Project owns that coordinate path The environment's working directory sits under the Project's directory, or under one of its repositories, and no other Project claims it The source is stored and shown, so the panel says \"adopted because it carriesportta.project: meu-produto\" rather than presenting a mapping with noexplanation. An ambiguous match adopts nothing and leaves the choice to you:an automatic adoption that is wrong is worse than none. An environment belongs to at most one Project; a Project may have any number,including none. What the API keeps separate GET /api/projects lists the product (the grouping). GET /api/environmentslists Compose stacks observed on this host. DELETE /api/projects/:slug removes the grouping and what only Porttaholds about it — its repositories, tasks, sessions and activity rows: nocontainer is stopped, no volume is removed, no environment is changed, and norepository is unlinked from GitHub. The response says so, because it is theendpoint most likely to be misread. Every Project endpoint needs the panel's database and answers 503 with ahint when it is unavailable; writes are refused in read-only mode. Project, environment, repository A project is a grouping a person creates. An environment is oneCompose project on this host (COMPOSE_PROJECT_NAME). A repositoryis a GitHub repository bound to a project. Today's projects table isthe environment; renaming it is part of building the new project entity. The Compose label portta.project remains a hint for groupingworktrees. It does not silently create a Portta project. What is stored, and what is not Stored, in the panel's own PostgreSQL: github_installations — which installations exist, for which account, withwhich permissions, and when they were last seen. github_repositories — the repositories those installations granted. Thistable is the authorisation boundary. github_sync_state — one row per sync scope, with its last run and its lasterror, so a failure is visible rather than silent. Every row carries synced_at, so the UI can always say how old an answer is —the same discipline the repository scan already applies. Never stored: the private key, the webhook secret, and any installationtoken. A token lives for an hour, is minted on demand, cached in memory withits expiry and refreshed early. No code path writes one to a row, a log line oran API response, and a test asserts that. Issues, and how they stay in step Issues are projected: GitHub owns them, and the panel keeps a local copy sothe board answers while GitHub is unreachable. Every row carries syncedAt anda staleness flag, exactly as ProjectGit carries collectedAt and stale. GitHub comments are deliberately not projected wholesale. They are large, they change often, anda link to GitHub beats a partial mirror — the same reasoningADR 0010 used for commit lists. Thereis no github_issue_comments table and a test asserts there is not. Portta comments are local entities. A user may explicitly publish one as acopy to the bound GitHub issue. Portta records the returned comment id and URL,and a failed publication remains retryable without losing the local comment. Issues and tasks A Task is Portta's own unit of work; it exists without GitHub. An issueon a repository a Project owns becomes a task bound to it — every existingissue did so in the migration that introduced tasks, and a new one does onthe next reconciliation. The board, portta tasks and portta mcp all workon tasks; owner/repo#number still addresses a bound one. A write to a bound task is persisted in Portta first and then pushed. When theApp is unavailable the local write succeeds and the binding is markedpending or error until POST /api/tasks/:ref/github/sync retries it. A remotechange that lands on a pending local edit is a conflict, kept and shownwith both sides and the names of the shared fields that differ. sync withresolve: local | remote settles the whole conflict; resolve: fields plus afield list takes those values from GitHub, keeps the remaining local sharedvalues and pushes the merge. Comments, parent, agent, type, service, due dateand draft stay local and never appear as conflicts; a draft is not publisheduntil it has a real title. See Tasks andADR 0033. Status and priority: fields where they exist, labels where they do not Not every account has GitHub's native issue types and project fields, so statusand priority are read through one abstraction with two implementations. Anative field wins where the repository has one; otherwise a documented labelconvention decides: Status Label Backlog status:backlog Ready status:ready In Progress status:in-progress Review status:review Blocked status:blocked Done status:done Priority Label Low priority:low Medium priority:medium High priority:high Urgent priority:urgent No caller knows which mechanism was used — but every response says which,in metadataSource, because it changes what a write does. Setting a statusthrough labels means adding one label and removing another, and that shows inthe issue's timeline. The panel marks a label-derived status so nobody issurprised by it. Only the dimension being changed is cleared, so setting a priority neversilently drops a status. Project workflows do not change this mapping. They only decide which catalogids a person may write inside that Project. A hidden status is simply unusedthere; an already-bound issue or local task that still carries it remainsreadable, and all six ids retain one stable GitHub representation. What this does not read: GitHub Projects v2 metadataSource is fields, labels or none. Projects v2 fields areGraphQL-only, and Portta has no GraphQL client, so a repository whose boardlives in a Project is invisible here — and worse, Portta's status:* labelwrites will not move its cards, which is exactly the second source of truthADR 0018 exists to forbid. The seam for it is deliberate: project would be a fourth MetadataSource,added only when a real repository demands it, together with the GraphQL clientit needs. Recorded as an extension point in ADR 0018's 2026-09-02 amendment, andnot a plan. Sub-issues and issue types are REST, and already in use. Three sync paths Path When How Initial A repository is newly authorised Page through its issues, project them, then resolve sub-issue links in a second pass so a child seen before its parent is not lost Reconciliation On demand, and every GITHUB_SYNC_INTERVAL_MINUTES (default 15; 0 turns the timer off) Ask only for issues updated since the stored cursor. Bounded per run; rate-limit pressure ends the run rather than failing it, and the next run resumes from the cursor. A tick that arrives while the previous pass is still running is skipped Webhook A delivery arrives A signal to re-read, never data to trust POST /api/integrations/github/sync runs the repository sync and areconciliation pass: one button, one meaning. The timer calls the samereconcile, so pressing the button is asking for the next pass now rather thanfor something different. The timer is what makes a loopback panel correct rather than merelypossible: it cannot receive a webhook delivery, so without it the projection isonly as fresh as the last time somebody pressed Sync. Turn it off withGITHUB_SYNC_INTERVAL_MINUTES=0 on a panel that does receive deliveries, whereit would otherwise do the same work twice. The webhook, and the hole it is allowed to make The panel refuses every unsafe method without a same-origin Origin header.GitHub sends none, so POST /api/integrations/github/webhook is exempt fromthat guard — narrowly, by exact path, and only because an HMAC signature overthe raw body replaces it. The signature is verified before the body is parsed as anything meaningful. Aninvalid one is a 401 that logs the delivery id and nothing else. Read-onlymode still refuses the route, and a delivery for a repository the installationnever granted changes nothing: the projection is the boundary, and deliveries donot widen it. Handled events: issues, label, milestone, sub_issues, pull_request,repository, installation, installation_repositories. Anything else —including issue_comment — is acknowledged and dropped, and an unhandled eventis not an error. Webhooks stay optional. A loopback panel cannot receive them, and correctnesscomes from reconciliation — which now runs on a timer as well as on demand, so\"correctness comes from reconciliation\" is a statement about what the panel doesrather than about what it could do. They are an optimisation for a panel youhave already published. Sub-issues Sub-issue links come from GitHub's own API and are stored as a graph thatcannot cycle: the database refuses a → a, and a longer path is refused bywalking the graph before the row is written. A link whose parent is in arepository the installation did not grant is dropped rather than dangling, sothe tree the UI renders always terminates. Writes go through GitHub PATCH /api/issues/:id writes to GitHub and then updates the projection fromwhat GitHub returned — never from what was requested. The panel never shows anissue GitHub did not confirm. It is refused in read-only mode, refused when noApp is configured, and refused for a repository outside the installation. What a status change actually does Provider Moving a card to Done Native fields Sets the field. Nothing appears in the issue's timeline Labels Adds status:done and removes the previous status: label. Both show in the timeline The panel marks a label-derived status so the difference is visible before youmove anything, and only the dimension being changed is cleared: setting apriority never silently drops a status. Pull requests: one source, stated The host gh scan and the App can both report open pull requests. When theApp is configured and the repository is authorised, the App wins; otherwisethe scan's forge block stands exactly as it does today. A panel with no Appsees GET /api/projects/:project/git behave precisely as before. See GitHub synchronization for the data flow.","headings":[{"id":"github-synchronization","text":"GitHub synchronization","level":1,"line":0},{"id":"the-issue-and-the-environment-it-is-worked-in","text":"The issue and the environment it is worked in","level":2,"line":4},{"id":"inferred-then-corrected-with-the-reason-recorded","text":"Inferred, then corrected, with the reason recorded","level":3,"line":21},{"id":"where-it-shows","text":"Where it shows","level":3,"line":42},{"id":"what-is-deliberately-not-built","text":"What is deliberately not built","level":3,"line":56},{"id":"what-stays-on-the-host","text":"What stays on the host","level":2,"line":64},{"id":"panel-responsibilities","text":"Panel responsibilities","level":2,"line":74},{"id":"source-of-truth","text":"Source of truth","level":2,"line":82},{"id":"projects-repositories-and-the-environments-that-belong-to-them","text":"Projects: repositories and the environments that belong to them","level":2,"line":103},{"id":"how-an-environment-is-adopted-and-why","text":"How an environment is adopted, and why","level":3,"line":125},{"id":"what-the-api-keeps-separate","text":"What the API keeps separate","level":3,"line":144},{"id":"project-environment-repository","text":"Project, environment, repository","level":2,"line":160},{"id":"what-is-stored-and-what-is-not","text":"What is stored, and what is not","level":2,"line":172},{"id":"issues-and-how-they-stay-in-step","text":"Issues, and how they stay in step","level":2,"line":193},{"id":"issues-and-tasks","text":"Issues and tasks","level":3,"line":208},{"id":"status-and-priority-fields-where-they-exist-labels-where-they-do-not","text":"Status and priority: fields where they exist, labels where they do not","level":3,"line":228},{"id":"what-this-does-not-read-github-projects-v2","text":"What this does not read: GitHub Projects v2","level":3,"line":265},{"id":"three-sync-paths","text":"Three sync paths","level":3,"line":278},{"id":"the-webhook-and-the-hole-it-is-allowed-to-make","text":"The webhook, and the hole it is allowed to make","level":3,"line":297},{"id":"sub-issues","text":"Sub-issues","level":3,"line":321},{"id":"writes-go-through-github","text":"Writes go through GitHub","level":3,"line":329},{"id":"what-a-status-change-actually-does","text":"What a status change actually does","level":3,"line":336},{"id":"pull-requests-one-source-stated","text":"Pull requests: one source, stated","level":3,"line":347}],"kind":"markdown"},{"slug":"development-setup","title":"Develop Portta","description":"Prepare an isolated checkout and run Portta with hot reload.","source":"docs/development/development-setup.md","audience":"developer","section":"Development","category":"","url":"/docs/development-setup","markdown":"# Develop Portta\n\nWork in an isolated checkout and namespace. Follow the [shared-host rules](../agent-guidelines.md) before starting or removing infrastructure.\n\n### Development, with hot reloading\n\n```bash\njust dev # gateway up, panel with hot reloading, pending SQL\njust db-migrate # apply pending SQL without a restart\n./bin/portta web dev # the panel alone, on a gateway already running\n```\n\nOne container, one port. The panel is a single process, so Next's HMR arrives on\nthe same `http://127.0.0.1:8081` the API answers on — there is no second server\nand no second port to remember.\n\n`apps/web/{app,components,lib,messages,server,public}`, `apps/auth/{src,ui}`,\n`packages/*/src`,\n`packages/db/drizzle` and the Markdown under `docs/` are bind-mounted, so the\nimage's `node_modules` stay in place. An edit to a page or a component reloads\nin the browser; an edit to `server/main.ts` or the ForwardAuth backend restarts\nits process, and its login UI rebuilds in watch mode. A newly\ngenerated migration is visible to the next `portta db migrate` without\nrebuilding the image.\n\nThe book icon and every `/docs/…` link stay on that same port: the documentation\nis a route of the panel, not a second site.\n\n`just dev` also scans `PORTTA_PROJECTS_HOME/portta-demo-*` and imports every\n`portta.example.json` it finds. It does not start those projects. Point the\ncheckout at the directory containing the example repositories:\n\n```bash\nPORTTA_PROJECTS_HOME=/path/to/portta-examples\n```\n\n`./bin/portta web up` goes back to the built image.\n\nIf you do have Node on the host and prefer to work outside containers:\n\n```bash\nnpm ci # from the repository root\nnpm run dev --workspace=portta-web # the panel on :8081\nnpm test --workspace=portta-web\nnpm run test:e2e --workspace=portta-web\nnpm run openapi --workspace=portta-contracts # refresh packages/contracts/openapi.json\n```\n\n### Formatting and static checks\n\n[Biome](https://biomejs.dev/) is the repository formatter, JavaScript/TypeScript\nlinter, and import organizer. Install the official\n[Biome editor extension](https://biomejs.dev/guides/editors/first-party-extensions/)\nand, if you prefer, enable format-on-save in your editor.\n\n```bash\nnpm run format # write formatting changes\nnpm run format:check # report formatting differences\nnpm run lint # run Biome lint rules\nnpm run check # report those issues in changed supported files\nnpm run fix # safely write Biome formatting, lint, and import fixes\n```\n\n`npm run check` is the non-writing CI gate and checks the supported files changed\nfrom `develop`; the full-repository commands remain useful while the existing\nformatting baseline is adopted incrementally. Biome covers supported JavaScript,\nTypeScript, JSON, and CSS files; it deliberately excludes generated artifacts such as\n`package-lock.json` and the committed OpenAPI document. TypeScript type checking,\nShellCheck, documentation validation, Compose checks, OpenAPI/schema checks, and tests\nremain separate checks with their existing commands.\n\n`npm run build --workspace=portta-web` is `next build` followed by an esbuild\nbundle of `server/main.ts` into `dist/server.mjs`. It needs the workspace\npackages built first (`core → contracts → db → server`): under\n`NODE_ENV=production` the `development` export condition no longer applies, so\neach resolves to its `dist/`. The image does exactly that, in that order.\n\n\n### Regenerating the screenshots\n\nThe images on this page and in the README are produced by the real panel, run\nagainst a fixed host described in `apps/web/e2e/demo-host.mjs`, a host metrics\nsnapshot the script writes itself (no collector runs), and a disposable\nPostgreSQL that imports deterministic manifests from\n`apps/web/e2e/fixtures/examples`. Every frame is\n1440×900 (`deviceScaleFactor` 2, so the files are 2880×1800):\n\n```bash\nnpm run screenshots --workspace=portta-web\n```\n\nThey are generated rather than taken by hand so they stay in step with the UI,\nshow the same thing every time, and never contain whatever happened to be\nrunning on the machine that produced them. Change the host in `demo-host.mjs`\nand the framing in `e2e/screenshots.mjs`.\n\n---\n\n## The panel in development\n\n`just dev` starts the panel and ForwardAuth with hot reloading. The panel stays\non **one port**: it is a single\nNode process — Next, the Hono API, the event stream and the WebSocket upgrades\nbehind one dispatcher — so `http://127.0.0.1:8081` is the API, the pages, the\ndocumentation and HMR. ForwardAuth watches its TypeScript process and rebuilds\nthe static login page when `apps/auth/ui` changes. The images provide Node and\ndependencies; source comes from bind mounts, so an ordinary edit does not build\nor recreate a container.\n\n\n## The panel's database\n\nPostgreSQL is required: the panel exits rather than starting without it, and\n`portta web up` brings it up alongside. Working on the schema is two commands:\n\n```bash\n# after editing packages/db/src/schema/*.ts\nnpm run db:generate --workspace=portta-db # write the migration\nnpm run db:check --workspace=portta-db # prove the schema and the SQL agree\nportta db migrate # apply it to a panel already running\n```\n\n`web-dev.yaml` mounts `packages/db/drizzle`, so a newly generated migration is\nvisible to the running container without rebuilding the image.\n\nSuites do not need any of this: they open PGlite and apply the same migrations\n(`createTestDb()` from `portta-db/testing`). See [persistence](../product/concepts/persistence.md).\n\n\n## Resetting a checkout\n\n> [!CAUTION]\n> Reset stops every Portta-managed stack on this host and drops their volumes,\n> including the panel database and development projects routed through this\n> gateway. Unrelated Compose projects are left alone. Verify ownership and back\n> up any state you need before proceeding.\n\n`portta dev --reset` stops Portta-managed containers, drops their volumes, and\nstarts again the same way `just dev` does. `portta reset` is that command.\nFlags pass through:\n\n```bash\njust reset # asks for confirmation on a TTY\njust reset --yes # same, non-interactive\njust reset --yes --demo # then recreate Projects Home examples and import their records\njust dev --reset --demo # the same sequence\n```\n\n**What takes the time.** The first `just dev` or `just reset` in a checkout,\nand any run after a dependency, lockfile or Dockerfile change, builds the shared\ndevelopment base. Source-only changes do not. It streams BuildKit's progress,\nand anything else that goes quiet reports how long it has been going.\n`just dev --verbose` shows every child process;\n`./bin/portta --quiet reset` shows none of it. A `Ctrl-C` during a build is\nsafe: BuildKit keeps the cache it has earned.\n\n**Gone.** Portta-managed containers and their named volumes: the panel database\n`${PORTTA_DB_VOLUME:-portta-db}` (Projects, tasks, tokens, activity, the GitHub\nprojection), development projects on the shared Portta network, example stacks\nunder `PORTTA_PROJECTS_HOME/portta-demo-*`, leftover managed helpers such as\nthe applier, runner and access bridges, and the snapshots `repos scan` and the\nhost collector rewrite under `state/git/` and `state/metrics/`.\n\n**Kept.** `.env`, GitHub App keys under `state/github/`, `state/auth/`, ACME\nand Tailscale material, shared networks, images, and Compose projects that are\nnot on this gateway's network and do not carry Portta labels.\n\nA plain `dev` imports example panel records without starting containers.\n`--demo` adds the complete runtime demonstration on `up`, `dev` and `reset`.\nDevelopment runs also create the checkout owner:\n\n```bash\njust dev --demo\n# sign in at the panel as admin@admin.com / secret\njust reset --yes --demo\n# same as: ./bin/portta reset --yes --demo\n```\n","text":"Develop Portta Work in an isolated checkout and namespace. Follow the shared-host rules before starting or removing infrastructure. Development, with hot reloading One container, one port. The panel is a single process, so Next's HMR arrives onthe same http://127.0.0.1:8081 the API answers on — there is no second serverand no second port to remember. apps/web/{app,components,lib,messages,server,public}, apps/auth/{src,ui},packages/*/src,packages/db/drizzle and the Markdown under docs/ are bind-mounted, so theimage's node_modules stay in place. An edit to a page or a component reloadsin the browser; an edit to server/main.ts or the ForwardAuth backend restartsits process, and its login UI rebuilds in watch mode. A newlygenerated migration is visible to the next portta db migrate withoutrebuilding the image. The book icon and every /docs/… link stay on that same port: the documentationis a route of the panel, not a second site. just dev also scans PORTTA_PROJECTS_HOME/portta-demo-* and imports everyportta.example.json it finds. It does not start those projects. Point thecheckout at the directory containing the example repositories: ./bin/portta web up goes back to the built image. If you do have Node on the host and prefer to work outside containers: Formatting and static checks Biome is the repository formatter, JavaScript/TypeScriptlinter, and import organizer. Install the officialBiome editor extensionand, if you prefer, enable format-on-save in your editor. npm run check is the non-writing CI gate and checks the supported files changedfrom develop; the full-repository commands remain useful while the existingformatting baseline is adopted incrementally. Biome covers supported JavaScript,TypeScript, JSON, and CSS files; it deliberately excludes generated artifacts such aspackage-lock.json and the committed OpenAPI document. TypeScript type checking,ShellCheck, documentation validation, Compose checks, OpenAPI/schema checks, and testsremain separate checks with their existing commands. npm run build --workspace=portta-web is next build followed by an esbuildbundle of server/main.ts into dist/server.mjs. It needs the workspacepackages built first (core → contracts → db → server): underNODE_ENV=production the development export condition no longer applies, soeach resolves to its dist/. The image does exactly that, in that order. Regenerating the screenshots The images on this page and in the README are produced by the real panel, runagainst a fixed host described in apps/web/e2e/demo-host.mjs, a host metricssnapshot the script writes itself (no collector runs), and a disposablePostgreSQL that imports deterministic manifests fromapps/web/e2e/fixtures/examples. Every frame is1440×900 (deviceScaleFactor 2, so the files are 2880×1800): They are generated rather than taken by hand so they stay in step with the UI,show the same thing every time, and never contain whatever happened to berunning on the machine that produced them. Change the host in demo-host.mjsand the framing in e2e/screenshots.mjs. The panel in development just dev starts the panel and ForwardAuth with hot reloading. The panel stayson one port: it is a singleNode process — Next, the Hono API, the event stream and the WebSocket upgradesbehind one dispatcher — so http://127.0.0.1:8081 is the API, the pages, thedocumentation and HMR. ForwardAuth watches its TypeScript process and rebuildsthe static login page when apps/auth/ui changes. The images provide Node anddependencies; source comes from bind mounts, so an ordinary edit does not buildor recreate a container. The panel's database PostgreSQL is required: the panel exits rather than starting without it, andportta web up brings it up alongside. Working on the schema is two commands: web-dev.yaml mounts packages/db/drizzle, so a newly generated migration isvisible to the running container without rebuilding the image. Suites do not need any of this: they open PGlite and apply the same migrations(createTestDb() from portta-db/testing). See persistence. Resetting a checkout [!CAUTION]Reset stops every Portta-managed stack on this host and drops their volumes,including the panel database and development projects routed through thisgateway. Unrelated Compose projects are left alone. Verify ownership and backup any state you need before proceeding. portta dev --reset stops Portta-managed containers, drops their volumes, andstarts again the same way just dev does. portta reset is that command.Flags pass through: What takes the time. The first just dev or just reset in a checkout,and any run after a dependency, lockfile or Dockerfile change, builds the shareddevelopment base. Source-only changes do not. It streams BuildKit's progress,and anything else that goes quiet reports how long it has been going.just dev --verbose shows every child process;./bin/portta --quiet reset shows none of it. A Ctrl-C during a build issafe: BuildKit keeps the cache it has earned. Gone. Portta-managed containers and their named volumes: the panel database${PORTTA_DB_VOLUME:-portta-db} (Projects, tasks, tokens, activity, the GitHubprojection), development projects on the shared Portta network, example stacksunder PORTTA_PROJECTS_HOME/portta-demo-*, leftover managed helpers such asthe applier, runner and access bridges, and the snapshots repos scan and thehost collector rewrite under state/git/ and state/metrics/. Kept. .env, GitHub App keys under state/github/, state/auth/, ACMEand Tailscale material, shared networks, images, and Compose projects that arenot on this gateway's network and do not carry Portta labels. A plain dev imports example panel records without starting containers.--demo adds the complete runtime demonstration on up, dev and reset.Development runs also create the checkout owner:","headings":[{"id":"develop-portta","text":"Develop Portta","level":1,"line":0},{"id":"development-with-hot-reloading","text":"Development, with hot reloading","level":3,"line":4},{"id":"formatting-and-static-checks","text":"Formatting and static checks","level":3,"line":48},{"id":"regenerating-the-screenshots","text":"Regenerating the screenshots","level":3,"line":78},{"id":"the-panel-in-development","text":"The panel in development","level":2,"line":98},{"id":"the-panels-database","text":"The panel's database","level":2,"line":110},{"id":"resetting-a-checkout","text":"Resetting a checkout","level":2,"line":129}],"kind":"markdown"},{"slug":"panel-architecture","title":"Panel architecture","description":"Understand the Next.js panel and its server boundaries.","source":"docs/development/panel-architecture.md","audience":"developer","section":"Development","category":"","url":"/docs/panel-architecture","markdown":"# Panel architecture\n\n## Architecture\n\n```text\nBrowser\n | http, loopback by default\nPanel (Next.js + Hono, one process, one container)\n |-- filtered Docker API, internal control network\nPanel socket proxy\n | read-only bind of the socket\nDocker\nPanel -- durable decisions --> PostgreSQL (private data network, no host port)\n```\n\nThe panel application is a single container running a single Node process, and\nthat process is a dispatcher over four things:\n\n```text\n/api/* the Hono API, including the event stream\nupgrade /ws/* WebSocket, authorised before the handshake\n/* Next's handler: the pages, their data, their assets\n```\n\n`apps/web/server/main.ts` composes them and `apps/web/server/compose.ts` decides\nwhich is which. One process because the panel is loopback by default with no\nproxy in front of it, a session cookie needs a single origin, and one container\nis what `portta web up` already starts.\n\nA page is a Server Component: it calls `services.*` from `portta-server`\ndirectly and never fetches the API this same process is serving. What it reads\nis handed to the client as `initialData`, so the first paint is the page rather\nthan a spinner, and the event stream keeps it alive from there. A mutation\nalways goes through `/api` — the same contract the CLI and MCP use.\n\nIt joins two networks: the gateway's shared network (so it can be published, and routed by\nTraefik when that is asked for) and its own `internal` control network, where\nits socket proxy lives. A third, dedicated internal network connects only the\npanel and its PostgreSQL database.\n\nIt never sees the Docker socket, has no Docker CLI, and reads exactly two\npaths from the host: `.env`, which its Settings page edits. Its image carries\nthe generated runtime version as an environment value.\n\nWhy a second socket proxy rather than Traefik's: Traefik's is read-only and\nmust stay that way, while the panel needs the container lifecycle. The two\npermission sets are kept apart, and the panel enforces its own allowlist on top\nof the proxy's. Its purpose-built client pins Docker Engine API `v1.43`, the\nAPI implemented by the project's minimum supported Docker Engine 24, so a\nnewer daemon cannot silently change the response contract. See\n[ADR 0008](adr/0008-web-panel-socket-proxy.md) and\n[ADR 0017](adr/0017-no-docker-sdk.md).\n\n### Technologies\n\n| Layer | Choice |\n|---|---|\n| Pages | [Next.js 16](https://nextjs.org/) App Router, React 19, Server Components by default |\n| Server | Node 24, TypeScript, a custom `http` server that dispatches to Next and Hono |\n| API | [Hono](https://hono.dev/), Zod for input validation, OpenAPI generated from the routes |\n| UI | Tailwind CSS 4, Radix primitives, TanStack Query, i18next |\n| Persistence | PostgreSQL 18, Drizzle ORM, generated migrations |\n| Live updates | Server-sent events, fed by Docker's own event stream |\n| Tests | Vitest (services, API, components, schema), Playwright (end to end) |\n\nThere is no Vite in the panel. The one Vite build left in the repository makes\nthe login page `apps/auth` serves, which is a separate service on a separate\norigin and may not import from the panel.\n\n### Where the code lives\n\n```text\napps/web/\n├── app/ routes. (panel)/ has the shell; docs/ is the documentation\n├── components/ ui/ primitives, shell/, entities/, tasks/, settings/\n├── lib/ api client, queries, live, i18n, docs collector, format\n├── messages/ en/*.json, pt-BR/*.json\n├── server/ main.ts (the process) and compose.ts (the dispatcher)\n└── public/ the favicon, and nothing that needs a request elsewhere\n```\n\n### Shell and navigation\n\nThe sidebar has two groups. **Development** — Overview and Projects — is the\ndaily flow; **Infrastructure** — Services, Docker, Network, Access, Gateway —\nis the technical perspective over the same host; Settings sits alone at the\nend. Each section sets a contextual browser title ending in `Portta`; a\nproject, task, repository or environment route refines it with its name. The\ntitle belongs to the route: every page exports `generateMetadata`, so tabs,\nbookmarks and history never inherit the previous page's title. The built UI also serves its SVG favicon\nlocally, with no browser request to a third-party asset.\n\nAt `md` and above, the sidebar can collapse from its 224px labelled form to a\n48px icon rail, with the `[` key or the control at its foot. The `portta-sidebar`\npreference survives reloads when local storage is available and safely defaults\nto expanded when it is not. Sections are links, so they open in a new tab like\nany link; icons keep tooltips and accessible labels, and the active section\ncarries `aria-current=\"page\"`. Below `md`, navigation remains the labelled\nhorizontal strip and the collapse control is hidden.\n\n`⌘K` (`Ctrl+K` elsewhere) opens the command menu: every section, every project\nand its tasks, the actions of the current page (a new task, folding the\nsidebar) and the preferences (theme, language). Typing narrows it; Enter runs\nthe highlighted entry. The visual language of the whole panel is described in\n[Design system](design-system.md).\n\nPostgreSQL stores decisions and identity, not observations. Everything live on\nscreen (services, URLs, networks, ports, health and bridges) is still read from\nDocker at request time, so a container that disappears simply stops appearing.\nThe database keeps the gateway instance, project identity, typed preferences\nand integration configuration. If it is down, the panel and its Docker-backed\npages remain available and diagnostics report the degraded state. See\n[Panel persistence](../product/concepts/persistence.md).\n\n---\n\n\n## Out of scope\n\nNot implemented, and not planned for this version: Kubernetes, deployments, a\nCompose editor, a host terminal, image management,\nvolume management, network management, arbitrary container creation, arbitrary\nTraefik configuration, an embedded Traefik dashboard, a tunnel service, or\nbeing a replacement for Portainer or Docker Desktop.\n\nTasks, the board, sessions, activity and the GitHub binding **shipped**;\n[Connect GitHub](../product/guides/github.md) and [MCP reference](../product/reference/mcp.md) describe them. What remains out\nof scope there: GitHub comments are never projected (reading one is a link to\nGitHub), GitHub Projects v2 fields are not read, and a web editor or a file\nbrowser beyond the instruction files is a later step. Local Git stays\nhost-collected ([ADR 0010](adr/0010-git-collected-on-the-host.md), amended by\n[ADR 0032](adr/0032-portta-development-model.md)).\n\nSharing is deliberately narrow: one additional hostname per service, with an\nexpiry, on a network the gateway already answers. It is not authentication for\na project and never becomes an identity layer.\n\nThe panel exists to make the gateway pleasant to use day to day, for people and\nfor agents, and to stop there.\n\nA console inside a running project container is implemented through the narrow\nDocker exec boundary in [ADR 0043](adr/0043-container-console-over-docker-exec.md).\nIt is not a host shell, an arbitrary command endpoint or a general container\nmanagement surface.\n\n## No Octokit\n\nThe panel image resolves three runtime dependencies, and that smallness is part\nof what makes it safe to run on a host that may be reachable over a VPN. What\nthis needs is an RS256 JWT, a token exchange, Link-header pagination and\nrate-limit accounting — about two hundred lines on `node:crypto` and `fetch`.\n\n**Added runtime dependencies: zero.** Revisit that choice if the integration\nsurface grows past what is honest to maintain directly.\n","text":"Panel architecture Architecture The panel application is a single container running a single Node process, andthat process is a dispatcher over four things: apps/web/server/main.ts composes them and apps/web/server/compose.ts decideswhich is which. One process because the panel is loopback by default with noproxy in front of it, a session cookie needs a single origin, and one containeris what portta web up already starts. A page is a Server Component: it calls services.* from portta-serverdirectly and never fetches the API this same process is serving. What it readsis handed to the client as initialData, so the first paint is the page ratherthan a spinner, and the event stream keeps it alive from there. A mutationalways goes through /api — the same contract the CLI and MCP use. It joins two networks: the gateway's shared network (so it can be published, and routed byTraefik when that is asked for) and its own internal control network, whereits socket proxy lives. A third, dedicated internal network connects only thepanel and its PostgreSQL database. It never sees the Docker socket, has no Docker CLI, and reads exactly twopaths from the host: .env, which its Settings page edits. Its image carriesthe generated runtime version as an environment value. Why a second socket proxy rather than Traefik's: Traefik's is read-only andmust stay that way, while the panel needs the container lifecycle. The twopermission sets are kept apart, and the panel enforces its own allowlist on topof the proxy's. Its purpose-built client pins Docker Engine API v1.43, theAPI implemented by the project's minimum supported Docker Engine 24, so anewer daemon cannot silently change the response contract. SeeADR 0008 andADR 0017. Technologies Layer Choice Pages Next.js 16 App Router, React 19, Server Components by default Server Node 24, TypeScript, a custom http server that dispatches to Next and Hono API Hono, Zod for input validation, OpenAPI generated from the routes UI Tailwind CSS 4, Radix primitives, TanStack Query, i18next Persistence PostgreSQL 18, Drizzle ORM, generated migrations Live updates Server-sent events, fed by Docker's own event stream Tests Vitest (services, API, components, schema), Playwright (end to end) There is no Vite in the panel. The one Vite build left in the repository makesthe login page apps/auth serves, which is a separate service on a separateorigin and may not import from the panel. Where the code lives Shell and navigation The sidebar has two groups. Development — Overview and Projects — is thedaily flow; Infrastructure — Services, Docker, Network, Access, Gateway —is the technical perspective over the same host; Settings sits alone at theend. Each section sets a contextual browser title ending in Portta; aproject, task, repository or environment route refines it with its name. Thetitle belongs to the route: every page exports generateMetadata, so tabs,bookmarks and history never inherit the previous page's title. The built UI also serves its SVG faviconlocally, with no browser request to a third-party asset. At md and above, the sidebar can collapse from its 224px labelled form to a48px icon rail, with the [ key or the control at its foot. The portta-sidebarpreference survives reloads when local storage is available and safely defaultsto expanded when it is not. Sections are links, so they open in a new tab likeany link; icons keep tooltips and accessible labels, and the active sectioncarries aria-current=\"page\". Below md, navigation remains the labelledhorizontal strip and the collapse control is hidden. ⌘K (Ctrl+K elsewhere) opens the command menu: every section, every projectand its tasks, the actions of the current page (a new task, folding thesidebar) and the preferences (theme, language). Typing narrows it; Enter runsthe highlighted entry. The visual language of the whole panel is described inDesign system. PostgreSQL stores decisions and identity, not observations. Everything live onscreen (services, URLs, networks, ports, health and bridges) is still read fromDocker at request time, so a container that disappears simply stops appearing.The database keeps the gateway instance, project identity, typed preferencesand integration configuration. If it is down, the panel and its Docker-backedpages remain available and diagnostics report the degraded state. SeePanel persistence. Out of scope Not implemented, and not planned for this version: Kubernetes, deployments, aCompose editor, a host terminal, image management,volume management, network management, arbitrary container creation, arbitraryTraefik configuration, an embedded Traefik dashboard, a tunnel service, orbeing a replacement for Portainer or Docker Desktop. Tasks, the board, sessions, activity and the GitHub binding shipped;Connect GitHub and MCP reference describe them. What remains outof scope there: GitHub comments are never projected (reading one is a link toGitHub), GitHub Projects v2 fields are not read, and a web editor or a filebrowser beyond the instruction files is a later step. Local Git stayshost-collected (ADR 0010, amended byADR 0032). Sharing is deliberately narrow: one additional hostname per service, with anexpiry, on a network the gateway already answers. It is not authentication fora project and never becomes an identity layer. The panel exists to make the gateway pleasant to use day to day, for people andfor agents, and to stop there. A console inside a running project container is implemented through the narrowDocker exec boundary in ADR 0043.It is not a host shell, an arbitrary command endpoint or a general containermanagement surface. No Octokit The panel image resolves three runtime dependencies, and that smallness is partof what makes it safe to run on a host that may be reachable over a VPN. Whatthis needs is an RS256 JWT, a token exchange, Link-header pagination andrate-limit accounting — about two hundred lines on node:crypto and fetch. Added runtime dependencies: zero. Revisit that choice if the integrationsurface grows past what is honest to maintain directly.","headings":[{"id":"panel-architecture","text":"Panel architecture","level":1,"line":0},{"id":"architecture","text":"Architecture","level":2,"line":2},{"id":"technologies","text":"Technologies","level":3,"line":53},{"id":"where-the-code-lives","text":"Where the code lives","level":3,"line":69},{"id":"shell-and-navigation","text":"Shell and navigation","level":3,"line":81},{"id":"out-of-scope","text":"Out of scope","level":2,"line":117},{"id":"no-octokit","text":"No Octokit","level":2,"line":145}],"kind":"markdown"},{"slug":"monorepo","title":"Monorepo layout","description":"Find workspace ownership and learn where new code belongs.","source":"docs/development/monorepo.md","audience":"developer","section":"Development","category":"","url":"/docs/monorepo","markdown":"# Monorepo layout\n\nWhere new code goes, and how a command is added. The decisions behind this\npage are [ADR 0014](adr/0014-monorepo-and-the-typescript-cli.md) and\n[ADR 0015](adr/0015-node-is-required-on-the-host.md).\n\nThe panel lives at `apps/web`, and it composes rather than implements: the\nservices and the HTTP API are `packages/server`, the shapes they answer with\nare `packages/contracts`, and the derivations the host and the CLI share are\n`packages/core`. The TypeScript CLI lives in `packages/cli` and is configured\nfor unscoped publication as `portta`. `npm ci` at the repository root installs\nevery workspace from one lockfile.\n\nOne thing about that lockfile is worth knowing before it costs an afternoon.\nNative bindings are optional dependencies chosen by platform, and npm records\n`cpu` and `os` for them but not always `libc` — so inside the Alpine image it\ncan pick the glibc build of a package where the musl one was needed, and the\nimage build fails on a missing `.node` a long way from anything you changed.\nWhere the choice matters and can be avoided, it is: the login page's CSS is\nminified by esbuild rather than by lightningcss for exactly this reason.\n\n## Map\n\n```text\nportta/\n├── apps/web/ the panel: Next.js pages and the process that serves them\n│ ├── app/ routes; (panel)/ carries the shell, docs/ the documentation\n│ ├── components/ ui/ primitives, shell/, entities/, tasks/, settings/\n│ ├── lib/ api client, queries, live, i18n, docs collector\n│ ├── messages/ en/*.json, pt-BR/*.json\n│ └── server/ main.ts (the process), compose.ts (the dispatcher)\n├── apps/auth/ the ForwardAuth service for project hostnames and shares\n├── packages/core/ portta-core — shared derivations (private)\n├── packages/contracts/ portta-contracts — API schemas, types, openapi.json\n├── packages/db/ portta-db — Drizzle schema, migrations, client\n├── packages/auth/ portta-auth-core — who is asking, and what they may do\n├── packages/server/ portta-server — services, Hono API, background work\n├── packages/cli/ portta — TypeScript CLI\n├── bin/portta Node checkout launcher for the same CLI\n├── scripts/ fixed runner-image entrypoint only\n├── docker/\n│ ├── compose/ gateway Compose base and overlays\n│ └── images/ operational image contexts (apply, toolbox)\n├── config/, docs/, tests/, templates/\n└── package.json workspaces: [\"apps/*\", \"packages/*\"]\n```\n\n| Workspace | Name | Published | Holds |\n|---|---|---|---|\n| `apps/web` | `portta-web` | no | The pages, the process that serves them, the Dockerfile, and the panel's own tests. No business rule |\n| `apps/auth` | `portta-auth` | no | ForwardAuth for project hostnames and shares |\n| `packages/core` | `portta-core` | no | Pure derivations: `env`, `config`, `discovery`, `capabilities`, `endpoints`, `inventory`, `apply`, `tunnel`, `password`, `metrics`. No process execution, ever |\n| `packages/contracts` | `portta-contracts` | no (the future SDK's source) | The API's Zod schemas and types, and the generated `openapi.json` |\n| `packages/db` | `portta-db` | no | The schema, the generated migrations and the client. No business rule |\n| `packages/auth` | `portta-auth-core` | no | Better Auth, the security mode, the `Principal`, and the one `authorize` |\n| `packages/server` | `portta-server` | no | Every business rule: services, the Hono API, Docker, Traefik, Git, GitHub, persistence, background work |\n| `packages/cli` | `portta` | ready, not published by repository changes | Commands, formatting, provisioning, and every effect: `process`, `docker`, `host`, `detect`, `metrics` |\n\n`bin/` and `scripts/` stay at the root. They are narrow runtime boundaries, not\nalternative command implementations, and are not a workspace.\n\nRunnable example projects are independent repositories under\n`PORTTA_PROJECTS_HOME`, conventionally named `portta-demo-*`. The reduced\nCompose and manifest inputs under `tests/fixtures/` exist only to keep Portta's\ntests self-contained.\n\n## The one rule\n\n> **Local facts come from Core, executed locally. Persistent decisions come\n> from the API. Nothing is implemented twice.**\n\nThe CLI never opens PostgreSQL. The panel is the only writer of durable\ndecisions ([ADR 0013](adr/0013-what-the-panel-persists.md)). Docker inventory,\nURLs, `.env`, `doctor`, Git collection, host metrics, `bootstrap` / `up` /\n`down` run locally through core.\n\n## Who may import whom\n\nAn arrow means \"may import\". Anything else is a defect, and\n`tests/unit/boundaries.test.sh` fails on it in milliseconds.\n\n```mermaid\nflowchart LR\n core[packages/core]\n contracts[packages/contracts]\n db[packages/db]\n auth[packages/auth]\n server[packages/server]\n web[apps/web]\n cli[packages/cli]\n fauth[apps/auth]\n\n contracts --> core\n db --> core\n auth --> core\n auth --> contracts\n auth --> db\n server --> core\n server --> contracts\n server --> db\n server --> auth\n web --> core\n web --> contracts\n web --> db\n web --> auth\n web --> server\n cli --> core\n cli --> contracts\n fauth --> core\n```\n\nRead the edges as consequences, not preferences:\n\n- **`packages/core` imports nothing from the monorepo.** It runs on the host,\n in the panel and in the CLI; a dependency would make one of the three\n unbuildable. It has a second entry point, `portta-core/browser`, holding the\n modules with no `node:*` in them so a bundle can use them; `slug` is the\n reason it exists.\n- **`packages/contracts` knows only core.** It is what the browser, the CLI and\n a future SDK compile against, so it cannot know a database exists. Its\n OpenAPI generator is a script, not source: it reaches for the server's routes,\n and nothing a consumer loads follows it.\n- **`packages/db` holds the shape of the rows and nothing else.** It has no\n business rule to ask `auth` or the services about, which is what lets a suite\n run the real migrations against PGlite without starting a panel. Its enums are\n built from the constants in `core`, so a vocabulary exists once.\n- **`packages/auth` answers one question and answers it once.** Who is asking,\n and what they may do. It owns Better Auth, the four roles, the permission\n vocabulary and `authorize`; the API, a Server Component and the event stream\n all read the same `Principal` from it. A second implementation of that\n decision is a second answer, and one of them will be wrong. It knows the\n database because the users are rows, and nothing else in it opens a socket.\n- **`packages/server` is the only place with a business rule**, and the only\n one that opens Docker, Traefik, Git, GitHub or PostgreSQL. It exports names,\n never `export *` from a service, so `apps/web` cannot reach past what it\n means to offer.\n- **`apps/web` composes.** A page calls a service through `lib/server/deps.ts`;\n it never fetches its own API from the server side, because the request would\n leave the process and come back through the same dispatcher to reach code the\n render already has.\n- **`packages/cli` never imports the server or the database.** It talks to the\n panel over HTTP, which is the rule that keeps \"the CLI never opens\n PostgreSQL\" true by construction rather than by care.\n\n## Where new code goes\n\n| You are adding… | It belongs in… |\n|---|---|\n| A panel page | `apps/web` `app/`, as a Server Component; a Client Component only where there is interaction |\n| A React component | `apps/web` `components/` |\n| An API route, or the rule behind one | `packages/server` — `src/api/routes/` for the route, `src/services/` for the rule. Never in `apps/web` |\n| A Zod schema the API answers with, or a type the CLI compiles against | `packages/contracts` |\n| A table, a column, an index or a check | `packages/db` `src/schema/`, then `npm run db:generate --workspace=portta-db`. Never SQL by hand |\n| A shared enum or vocabulary both the schema and the CLI need | `packages/core`, named once, with `packages/contracts` deriving its schema from it |\n| Parsing `.env`, inventory, Traefik files, the Docker allowlist | `packages/core`, the first time a second consumer needs it |\n| A CLI command | `packages/cli` `src/commands/`, colocated `*.test.ts` |\n| Host diagnostics, Compose, filesystem provisioning | `packages/cli` calling `packages/core` |\n| A host probe: an address, a tool's location, a file mode | `packages/cli` `src/host.ts`, with the verdict it feeds in `packages/core` |\n| Host and project resource metrics | Types and normalizers in `packages/core` `metrics.ts`; collection in `packages/cli` `src/metrics/`. The panel only reads the files. See [Host metrics](../product/reference/host-metrics.md) |\n| Anything else you were about to write in Bash | `packages/cli`. See [shell scripts](scripts.md): being the interface to `openssl`, `ssh` or `docker run` is not a reason |\n| Persistent settings, project overrides, integrations | The panel API, never a second database client |\n| A document | `docs/product/` or `docs/development/`, classified in `docs/navigation.json`; see [Contribute documentation](documentation.md) |\n\nDo not put panel-only code in `packages/core` \"for later\". A module enters\ncore when a second consumer exists, not in anticipation.\n\n## How to add a command\n\n1. Decide the path with the rule above. If the command needs a fact from\n Docker, Git or the host, it runs locally. If it needs a preference stored\n by the panel, it calls the API.\n2. If the behaviour already exists in the panel, extract the shared function\n into `packages/core` in the same change that the CLI starts calling it.\n3. Put the command module at `packages/cli/src/commands/<name>.ts` with a\n colocated test. Headless-first: plain output, colour only when `stdout` is\n a TTY, `--json` for agents.\n4. `node dist/cli.js --help` must still start. A load-time defect is invisible\n to unit tests that never import the entry point.\n\n## Node on the host\n\nThe TypeScript CLI needs Node 22.12+. The unscoped npm package and the binary\nare both named `portta`.\nDetails in [ADR 0015](adr/0015-node-is-required-on-the-host.md).\n\n## AGENTS.md\n\nThe root `AGENTS.md` is an index and holds the repository-wide agent testing policy. Per-directory\n`AGENTS.md` files are added only when a workspace has rules that are not true\nof the rest of the repository, starting with `packages/cli` and\n`packages/core` when they gain code. Document once; reference everywhere it\nis needed. The operating rules for agents on a shared host already live in\n[agent-guidelines.md](../agent-guidelines.md).\n\n## Documentation knowledge boundary\n\nCore owns pure documentation compilation and search. Contracts owns API schemas. The server loads the corpus and exposes read endpoints. Next renders the corpus directly; CLI and MCP read their bundled corpus or an explicitly selected panel. Build tooling reads source files and emits distribution artifacts. No interface keeps an independently edited copy.\n","text":"Monorepo layout Where new code goes, and how a command is added. The decisions behind thispage are ADR 0014 andADR 0015. The panel lives at apps/web, and it composes rather than implements: theservices and the HTTP API are packages/server, the shapes they answer withare packages/contracts, and the derivations the host and the CLI share arepackages/core. The TypeScript CLI lives in packages/cli and is configuredfor unscoped publication as portta. npm ci at the repository root installsevery workspace from one lockfile. One thing about that lockfile is worth knowing before it costs an afternoon.Native bindings are optional dependencies chosen by platform, and npm recordscpu and os for them but not always libc — so inside the Alpine image itcan pick the glibc build of a package where the musl one was needed, and theimage build fails on a missing .node a long way from anything you changed.Where the choice matters and can be avoided, it is: the login page's CSS isminified by esbuild rather than by lightningcss for exactly this reason. Map Workspace Name Published Holds apps/web portta-web no The pages, the process that serves them, the Dockerfile, and the panel's own tests. No business rule apps/auth portta-auth no ForwardAuth for project hostnames and shares packages/core portta-core no Pure derivations: env, config, discovery, capabilities, endpoints, inventory, apply, tunnel, password, metrics. No process execution, ever packages/contracts portta-contracts no (the future SDK's source) The API's Zod schemas and types, and the generated openapi.json packages/db portta-db no The schema, the generated migrations and the client. No business rule packages/auth portta-auth-core no Better Auth, the security mode, the Principal, and the one authorize packages/server portta-server no Every business rule: services, the Hono API, Docker, Traefik, Git, GitHub, persistence, background work packages/cli portta ready, not published by repository changes Commands, formatting, provisioning, and every effect: process, docker, host, detect, metrics bin/ and scripts/ stay at the root. They are narrow runtime boundaries, notalternative command implementations, and are not a workspace. Runnable example projects are independent repositories underPORTTA_PROJECTS_HOME, conventionally named portta-demo-*. The reducedCompose and manifest inputs under tests/fixtures/ exist only to keep Portta'stests self-contained. The one rule Local facts come from Core, executed locally. Persistent decisions comefrom the API. Nothing is implemented twice. The CLI never opens PostgreSQL. The panel is the only writer of durabledecisions (ADR 0013). Docker inventory,URLs, .env, doctor, Git collection, host metrics, bootstrap / up /down run locally through core. Who may import whom An arrow means \"may import\". Anything else is a defect, andtests/unit/boundaries.test.sh fails on it in milliseconds. Read the edges as consequences, not preferences: packages/core imports nothing from the monorepo. It runs on the host,in the panel and in the CLI; a dependency would make one of the threeunbuildable. It has a second entry point, portta-core/browser, holding themodules with no node:* in them so a bundle can use them; slug is thereason it exists. packages/contracts knows only core. It is what the browser, the CLI anda future SDK compile against, so it cannot know a database exists. ItsOpenAPI generator is a script, not source: it reaches for the server's routes,and nothing a consumer loads follows it. packages/db holds the shape of the rows and nothing else. It has nobusiness rule to ask auth or the services about, which is what lets a suiterun the real migrations against PGlite without starting a panel. Its enums arebuilt from the constants in core, so a vocabulary exists once. packages/auth answers one question and answers it once. Who is asking,and what they may do. It owns Better Auth, the four roles, the permissionvocabulary and authorize; the API, a Server Component and the event streamall read the same Principal from it. A second implementation of thatdecision is a second answer, and one of them will be wrong. It knows thedatabase because the users are rows, and nothing else in it opens a socket. packages/server is the only place with a business rule, and the onlyone that opens Docker, Traefik, Git, GitHub or PostgreSQL. It exports names,never export * from a service, so apps/web cannot reach past what itmeans to offer. apps/web composes. A page calls a service through lib/server/deps.ts;it never fetches its own API from the server side, because the request wouldleave the process and come back through the same dispatcher to reach code therender already has. packages/cli never imports the server or the database. It talks to thepanel over HTTP, which is the rule that keeps \"the CLI never opensPostgreSQL\" true by construction rather than by care. Where new code goes You are adding… It belongs in… A panel page apps/web app/, as a Server Component; a Client Component only where there is interaction A React component apps/web components/ An API route, or the rule behind one packages/server — src/api/routes/ for the route, src/services/ for the rule. Never in apps/web A Zod schema the API answers with, or a type the CLI compiles against packages/contracts A table, a column, an index or a check packages/db src/schema/, then npm run db:generate --workspace=portta-db. Never SQL by hand A shared enum or vocabulary both the schema and the CLI need packages/core, named once, with packages/contracts deriving its schema from it Parsing .env, inventory, Traefik files, the Docker allowlist packages/core, the first time a second consumer needs it A CLI command packages/cli src/commands/, colocated *.test.ts Host diagnostics, Compose, filesystem provisioning packages/cli calling packages/core A host probe: an address, a tool's location, a file mode packages/cli src/host.ts, with the verdict it feeds in packages/core Host and project resource metrics Types and normalizers in packages/core metrics.ts; collection in packages/cli src/metrics/. The panel only reads the files. See Host metrics Anything else you were about to write in Bash packages/cli. See shell scripts: being the interface to openssl, ssh or docker run is not a reason Persistent settings, project overrides, integrations The panel API, never a second database client A document docs/product/ or docs/development/, classified in docs/navigation.json; see Contribute documentation Do not put panel-only code in packages/core \"for later\". A module enterscore when a second consumer exists, not in anticipation. How to add a command Decide the path with the rule above. If the command needs a fact fromDocker, Git or the host, it runs locally. If it needs a preference storedby the panel, it calls the API. If the behaviour already exists in the panel, extract the shared functioninto packages/core in the same change that the CLI starts calling it. Put the command module at packages/cli/src/commands/<name>.ts with acolocated test. Headless-first: plain output, colour only when stdout isa TTY, --json for agents. node dist/cli.js --help must still start. A load-time defect is invisibleto unit tests that never import the entry point. Node on the host The TypeScript CLI needs Node 22.12+. The unscoped npm package and the binaryare both named portta.Details in ADR 0015. AGENTS.md The root AGENTS.md is an index and holds the repository-wide agent testing policy. Per-directoryAGENTS.md files are added only when a workspace has rules that are not trueof the rest of the repository, starting with packages/cli andpackages/core when they gain code. Document once; reference everywhere itis needed. The operating rules for agents on a shared host already live inagent-guidelines.md. Documentation knowledge boundary Core owns pure documentation compilation and search. Contracts owns API schemas. The server loads the corpus and exposes read endpoints. Next renders the corpus directly; CLI and MCP read their bundled corpus or an explicitly selected panel. Build tooling reads source files and emits distribution artifacts. No interface keeps an independently edited copy.","headings":[{"id":"monorepo-layout","text":"Monorepo layout","level":1,"line":0},{"id":"map","text":"Map","level":2,"line":21},{"id":"the-one-rule","text":"The one rule","level":2,"line":66},{"id":"who-may-import-whom","text":"Who may import whom","level":2,"line":76},{"id":"where-new-code-goes","text":"Where new code goes","level":2,"line":144},{"id":"how-to-add-a-command","text":"How to add a command","level":2,"line":166},{"id":"node-on-the-host","text":"Node on the host","level":2,"line":179},{"id":"agentsmd","text":"AGENTS.md","level":2,"line":185},{"id":"documentation-knowledge-boundary","text":"Documentation knowledge boundary","level":2,"line":194}],"kind":"markdown"},{"slug":"database-development","title":"Develop the database schema","description":"Change schemas and validate generated migrations.","source":"docs/development/database-development.md","audience":"developer","section":"Development","category":"","url":"/docs/database-development","markdown":"# Develop the database schema\n\nThe panel owns durable decisions; `packages/db` owns their schema.\n\n## Where the schema lives\n\n`packages/db` owns the schema, the migrations and the client, and holds no\nbusiness rule. `packages/server` owns the rules and reaches the tables through\nit. The split is what lets a suite run the real migrations against an in-memory\nPostgreSQL without starting a panel.\n\n```text\npackages/db/\n├── drizzle/ 0000_current.sql and its journal — generated, never hand-written\n├── drizzle.config.ts\n└── src/\n ├── schema/ one file per area; the tables, checks, indexes and relations\n ├── client.ts createDb(url) → { db, sql }\n ├── migrate.ts migrateWithLock(url): advisory lock, then the migrator\n ├── seed.ts seedMinimal(db): the instance row, and nothing else\n └── test-db.ts createTestDb(): PGlite, migrated\n```\n\n## Changing the schema\n\nThe schema is TypeScript; the SQL is generated from it and committed.\n\n```bash\n# 1. edit packages/db/src/schema/*.ts\nnpm run db:generate --workspace=portta-db # writes drizzle/NNNN_name.sql and its snapshot\n# 2. read the SQL it produced, then commit both\nnpm run db:check --workspace=portta-db # fails if the schema and the SQL disagree\n```\n\nNothing in `packages/db/drizzle/` is written by hand. `db:check` runs the\ngenerator and fails if it wanted to write anything, which is the only way to\nnotice a column added to the schema and never generated; `npm run test:integration` runs it in a disposable directory.\n\nApplied migrations are recorded in `drizzle_migrations`. Startup takes a\nsession-level advisory lock and applies what is pending, so two panels starting\nat once cannot partially apply one; a failure there is a failure to boot.\n`portta db migrate` applies what is pending without a restart, which is what\nmakes a newly generated file visible to a panel that is already up.\n\nThere is one migration, `0000_current`, representing the complete schema of\nthis release.\n\nIn a checkout, `docker/compose/features/web-dev.yaml` bind-mounts\n`packages/db/drizzle` into the panel container. `portta db migrate` requests\n`POST /api/database/migrate`; it does not open a second database connection\nfrom the CLI. Production reads the migrations packaged with that release.\n\n## Testing against it\n\nSuites open [PGlite](https://pglite.dev) — PostgreSQL compiled to WebAssembly —\nand apply the same migrations. Checks, enums, cascades, advisory locks and\n`jsonb` are the real ones, so a query the panel gets wrong fails in the suite\nrather than in production.\n\n```ts\nimport { createTestDb } from 'portta-db/testing'\n\nconst { db, close } = await createTestDb()\n```\n\nCreate an isolated instance for each test and close it afterward. The test helper reuses an immutable migrated template within the test module; it never shares mutable database state. See [Testing](testing.md) for current guidance.\n","text":"Develop the database schema The panel owns durable decisions; packages/db owns their schema. Where the schema lives packages/db owns the schema, the migrations and the client, and holds nobusiness rule. packages/server owns the rules and reaches the tables throughit. The split is what lets a suite run the real migrations against an in-memoryPostgreSQL without starting a panel. Changing the schema The schema is TypeScript; the SQL is generated from it and committed. Nothing in packages/db/drizzle/ is written by hand. db:check runs thegenerator and fails if it wanted to write anything, which is the only way tonotice a column added to the schema and never generated; npm run test:integration runs it in a disposable directory. Applied migrations are recorded in drizzle_migrations. Startup takes asession-level advisory lock and applies what is pending, so two panels startingat once cannot partially apply one; a failure there is a failure to boot.portta db migrate applies what is pending without a restart, which is whatmakes a newly generated file visible to a panel that is already up. There is one migration, 0000_current, representing the complete schema ofthis release. In a checkout, docker/compose/features/web-dev.yaml bind-mountspackages/db/drizzle into the panel container. portta db migrate requestsPOST /api/database/migrate; it does not open a second database connectionfrom the CLI. Production reads the migrations packaged with that release. Testing against it Suites open PGlite — PostgreSQL compiled to WebAssembly —and apply the same migrations. Checks, enums, cascades, advisory locks andjsonb are the real ones, so a query the panel gets wrong fails in the suiterather than in production. Create an isolated instance for each test and close it afterward. The test helper reuses an immutable migrated template within the test module; it never shares mutable database state. See Testing for current guidance.","headings":[{"id":"develop-the-database-schema","text":"Develop the database schema","level":1,"line":0},{"id":"where-the-schema-lives","text":"Where the schema lives","level":2,"line":4},{"id":"changing-the-schema","text":"Changing the schema","level":2,"line":23},{"id":"testing-against-it","text":"Testing against it","level":2,"line":52}],"kind":"markdown"},{"slug":"scripts","title":"Shell scripts","description":"Understand the remaining shell contract and how to add commands.","source":"docs/development/scripts.md","audience":"developer","section":"Development","category":"","url":"/docs/scripts","markdown":"# Shell scripts\n\nPortta has one product implementation: the TypeScript CLI in `packages/cli`.\nNew commands and operational behavior belong there, with shared derivations in\n`packages/core`. Product shell code must not duplicate a command, validation,\ndefault or configuration writer.\n\nThe repository contains only these deliberate shell boundaries:\n\n| Path | Purpose |\n|---|---|\n| `install.sh` | Verify Node/npm and hand installation to the published `portta` package |\n| `scripts/lib/runner-exec.sh` | Fixed entrypoint inside the isolated project-runner image |\n| `tests/**/*.sh` | Docker-backed assertions and their test-only helpers |\n\n`bin/portta` is a Node launcher. In a checkout it rebuilds `packages/cli` when\nthe source is newer than the bundle, then executes that same bundle. In an npm\ninstallation, the package's `bin` entry executes the bundled CLI directly.\n\n## Adding behavior\n\nAdd a command under `packages/cli/src/commands/` and a colocated test. Invoke\nexternal programs through `runProcess`, passing an executable and argument\narray. Use `packages/core` when a pure rule has more than one consumer.\n\nA new shell file needs an actual process boundary that cannot live in Node; a\npreference for shell syntax or a call to Docker, Git, SSH, OpenSSL or curl is\nnot such a boundary.\n\n## Validation\n\nRun the command's targeted Vitest file and `npm run build --workspace=portta`\nwhen the executable or packaging changes. Shell files are checked by\n`tests/lint.sh`; executable bits are required only for `install.sh` and\n`bin/portta`.\n","text":"Shell scripts Portta has one product implementation: the TypeScript CLI in packages/cli.New commands and operational behavior belong there, with shared derivations inpackages/core. Product shell code must not duplicate a command, validation,default or configuration writer. The repository contains only these deliberate shell boundaries: Path Purpose install.sh Verify Node/npm and hand installation to the published portta package scripts/lib/runner-exec.sh Fixed entrypoint inside the isolated project-runner image tests/**/*.sh Docker-backed assertions and their test-only helpers bin/portta is a Node launcher. In a checkout it rebuilds packages/cli whenthe source is newer than the bundle, then executes that same bundle. In an npminstallation, the package's bin entry executes the bundled CLI directly. Adding behavior Add a command under packages/cli/src/commands/ and a colocated test. Invokeexternal programs through runProcess, passing an executable and argumentarray. Use packages/core when a pure rule has more than one consumer. A new shell file needs an actual process boundary that cannot live in Node; apreference for shell syntax or a call to Docker, Git, SSH, OpenSSL or curl isnot such a boundary. Validation Run the command's targeted Vitest file and npm run build --workspace=porttawhen the executable or packaging changes. Shell files are checked bytests/lint.sh; executable bits are required only for install.sh andbin/portta.","headings":[{"id":"shell-scripts","text":"Shell scripts","level":1,"line":0},{"id":"adding-behavior","text":"Adding behavior","level":2,"line":19},{"id":"validation","text":"Validation","level":2,"line":29}],"kind":"markdown"},{"slug":"testing","title":"Testing","description":"Choose targeted tests and integration or release validation.","source":"docs/development/testing.md","audience":"developer","section":"Development","category":"","url":"/docs/testing","markdown":"# Testing\n\n> Targeted while developing → affected scope when finishing an ordinary task →\n> broad regression at integration → full/E2E for release or an explicit reason.\n\nFinishing a feature increment, answering a user, or handing off a small task is\nnot an integration milestone. Run the smallest test that protects the changed\nbehavior. Do not repeat passing checks unless relevant code changed afterward.\nThe agent policy in [AGENTS.md](../../AGENTS.md) governs what to test and\nwhich tests to run. Keep a test only when its value clearly justifies the time\nto create, run and maintain it.\n\n## Choose a test\n\nEvery Node workspace accepts a Vitest filename and test-name filter. Prefer a\nfull relative filename; a substring such as `tasks` can select several files.\nCombine a filename with `-t` to avoid importing unrelated files. Check the\nexecuted test count: Vitest can exit successfully when a name matches no cases.\n\n```bash\nnpm test --workspace=portta-server -- tests/tasks.test.ts -t 'creates, lists, reads and deletes'\nnpm test --workspace=portta-core -- src/config.test.ts\nnpm test --workspace=portta-web -- --project ui tests/ui/settings-users.test.tsx\nnpm test --workspace=portta-web -- --project logic tests/logic/health.test.ts\nnpm test --workspace=portta-web -- --project server tests/server/compose.test.ts\nnpm test --workspace=portta-web -- --project docs\nbash tests/unit/install.test.sh\n```\n\nFor a module, give its test directory or several filenames. A workspace suite\nis the fallback when no reliable narrower selection exists. Types have the\nsame scope: `npm run typecheck --workspace=portta-server`, for example.\n\n```bash\nnpm run test:affected # list local changes against HEAD\nnpm run test:affected -- --base origin/main # merge-base plus all local changes\nnpm run test:affected -- --base origin/main --run\n```\n\nThe selector shows the changed files, commands, reasons, recommendations and\nunmapped paths. It includes staged, unstaged, untracked, renamed and removed\nfiles. `--run` refuses a plan with gaps before executing anything. Source files\nwithout an exact test fall back to their workspace; shared packages select\ntransitive workspace consumers. Configuration and unknown files require an\nexplicit integration decision. Browser and gateway scenarios are recommendations,\nnever silently started by diff selection. Reviewing a recommendation is still\nnecessary: a passing selected scope is not a release certificate.\n\nThe selector is deliberately conservative. It does not claim that an import\ngraph covers SQL, shell sourcing, dynamically imported files, or documentation\nread from disk. No remote cache or external monorepo task system is required.\n\n## Layers and commands\n\n| Layer | Trigger | Command |\n| --- | --- | --- |\n| Development | A coherent small change | Filename/case commands above |\n| Affected | Several related modules or a changed shared boundary | Review `test:affected`, then `--run` or explicit suites |\n| Integration | PR/meaningful merge milestone | `npm run test:integration` |\n| E2E | Changed real system interaction | `npm run test:e2e -- --suite lifecycle` or `--spec roles.spec.ts` |\n| Release | Candidate before publishing | `npm run test:release` |\n\nIntegration runs static lint (including Compose), shell suites, tooling tests,\nall workspace tests, scoped typechecks, OpenAPI and schema drift checks. It\nbuilds core and CLI first so entrypoint smoke uses this checkout's compiled\nentrypoint. It does not rebuild the panel or start lifecycle containers.\nSome host-observation assertions are conditional; their skips are recorded.\nRequired static tools (including cloudflared for connector parsing), Node dependencies and selected browser prerequisites\nmust be present. A name-filtered Vitest execution with zero cases fails in the\norchestrated commands.\n\n`node tests/run.mjs` means integration with an explicit notice. `--lint` runs\nstatic checks only, `--e2e` runs isolated system scenarios, and `--release`\nruns the complete release gate. Extra or unknown arguments are errors.\n\n`just test` still means broad integration; use the npm filename commands for\nroutine work.\n\nEvery orchestrated stage records wall-clock, status, command, skips and a log in\n`test-results/<run>/`. Successful stages print a short summary; failed stages\nprint their diagnostics once. Browser JSON and failure traces live in\n`apps/web/test-results/`. CI uploads these artifacts.\n\n## Change-to-test matrix\n\nThe minimum is the directly related case/file, not every example in a row.\nWiden only for an actual consumer, contract or risk. Integration below applies\nto code/configuration PRs; documentation-only PRs check links. A release runs the\ncomplete release gate once, rather than repeating it for each category.\n\n| Change | Minimum | Widen when affected | Real-system check before merge when applicable |\n| --- | --- | --- | --- |\n| React component | UI file for functional behavior | Consumers, web types | Related browser interaction only if the flow is critical |\n| Copy, colors, icons or styles | Review or `playwright-cli` | None | None |\n| Width/layout tweaks | Review or `playwright-cli` | None | None automatically |\n| API | Route case | Contract, service, authorization; OpenAPI if it can change | HTTP dispatcher/browser boundary |\n| Service | Rule case | Routes and callers | External integration changed |\n| Repository | PGlite query/write case | Service caller | PostgreSQL driver-specific behavior |\n| Schema | Constraint/cascade test + `db:check` | Repositories | PostgreSQL behavior if different |\n| Migration | Fresh migration/idempotence/data preservation + `db:check` | Repositories | Upgrade against PostgreSQL |\n| Contract | Schema test + OpenAPI if affected | Typed consumers and route | External API flow |\n| Shared core | Core file | Consumers of the changed export | Parity/routing if changed |\n| CLI | Command test | CLI build + entrypoint smoke if packaging changed | Relevant command scenario |\n| Shell | Subject suite + shellcheck | TypeScript parity | Relevant gateway scenario |\n| Installer | Install/maintenance fixture | Audit, upgrade/refusal cases | Disposable install/lifecycle |\n| Compose | Affected profile/template | Consumers and matrix | Affected live services |\n| Traefik/TCP/TLS | Routing derivation/refusal | Compose and discovery | TCP/TLS with distinct databases |\n| Authentication | Auth-core/ForwardAuth case | API security/principal/scope | auth/roles/settings as affected |\n| Security | Changed refusal/boundary | All callers of the policy | Corresponding attack/session path |\n| Documentation | Links | Docs collector if behavior changed | None automatically |\n| Build/packaging | Owning build and entrypoint smoke | Types, consumers | Runtime image as affected |\n| Monorepo structure | Boundaries and affected compilation | Transitive consumers | Integration, broader if impact is unclear |\n\n## What the tests protect\n\nKeep tests for business rules, refusals, exit codes, authorization boundaries,\nsecret handling, data-loss paths and previous bugs. Do not keep tests for CSS\nclasses, copy, visual tone, spacing or one-off layout. Simple visual changes\nare checked with `playwright-cli`, not a new or existing test file.\n\nThe role matrix, API origin and scope guards, and browser session tests protect\ndifferent boundaries. An Engine API fake does not replace the Docker-backed\npanel scenario. Bash and TypeScript implementations both ship and still need\nparity checks.\n\nPrefer assertions on observable arguments/results to source strings, labels or\nclass names. Do not remove a security assertion until another test demonstrably\nprotects the same failure mode. Don't use a browser or database just to test a\npure function; don't replace real constraints/transactions with permissive mocks.\nDrive polling timers with fake timers where the timer is the subject. Do not\nreplace real socket or protocol behavior with artificial clocks.\n\nThe panel has four Vitest projects: `logic` (pure Node derivations), `ui`\n(jsdom/components), `server` (Node dispatcher), and `docs` (Node collection).\nThe pools, worker defaults and per-file isolation remain unchanged. Do not turn\non `isolate: false` or concurrent cases over mutable fixtures merely to improve\na benchmark. Compare a representative sample before changing workers.\n\n## PostgreSQL and PGlite\n\n`createTestDb()` creates an independent PostgreSQL/WASM instance on every call.\nIt lazily caches only an immutable, unseeded image produced by the real\nmigrations, in the current test module. The template client is closed after\nexport. No persistent snapshot cache can outlive a schema/version change.\n\n```ts\nconst { db, close } = await createTestDb()\n// migration tests must execute SQL directly, without restoring the image\nconst fresh = await createTestDb({ fresh: true })\n```\n\nAlways close instances. Schema tests retain real constraints, foreign keys,\ntransactions and sequences. Migration tests explicitly use fresh instances.\nSnapshots contain no shared mutable rows. `seededDatabase()` adds the Project,\nrepository and environments needed by service tests. Avoid redundant seeds.\n\n`db:check` copies schema/config/migrations to a temporary package, runs the\nactual generator there, compares SQL and metadata, and removes the temporary\npackage even on failure. It never edits the checkout's journal or snapshots.\n\n## E2E ownership, selection and CI\n\nGateway scripts refuse direct execution on a shared daemon. The launcher builds\na disposable host with its own Docker daemon, copies source (without local\ncredentials/state), and runs selected scenarios there. No host Docker socket or\nhost checkout is mounted. Cleanup verifies ownership and removes only that host\nand its anonymous data volume. This requires Docker support for privileged\nnested containers; failures are reported, never converted to skips.\n\nBrowser specs each own a fresh PostgreSQL, a loopback access bridge, an Engine\nAPI fake and a panel. PostgreSQL publishes no host port. Resource names include\na random invocation identity; teardown checks labels. The browser never reuses\na pre-existing server. Every invocation builds dependencies and the panel once\nbefore workers start. Concurrent E2E builds are refused by a lock.\n\nRoles/settings fixtures create their own owner. Auth bootstrap stays a real UI\nscenario, and its wrong-password test also works alone. Retries get fresh worker\nresources. Native Playwright filters still work:\n\n```bash\nnpm run test:e2e --workspace=portta-web -- roles.spec.ts\nnpm run test:e2e --workspace=portta-web -- auth.spec.ts -g 'password that is wrong'\n```\n\nThe manual screenshots/viewports tools use the same owned resources. They are\noptional layout checks, not automated tests to create or keep for simple visual\nchanges. Use `playwright-cli` for that kind of verification.\n\nGitHub Actions validates code PRs with integration and chooses relevant E2E\nfamilies for auth, routing, lifecycle and harness changes. Pure component changes\ndo not automatically run browsers. Tags run all E2E families. Publication depends\non the reusable validation job; failed prerequisites/tests prevent publication.\nNo schedule, sharding or remote result cache is introduced.\n\n## Measured costs\n\nAudit baseline on 2026-09-05: macOS arm64, 11 available CPUs, 18 GB, Node 22.22.1,\nVitest 4.1.11, installed dependencies and existing build caches. These are single\nobservations, not universal budgets or cold-install benchmarks.\n\n| Baseline scope | Wall-clock |\n| --- | ---: |\n| One API case with PGlite | 2.10 s |\n| One pure helper file under the former UI project | 0.77 s |\n| Core / contracts / CLI / ForwardAuth | 1.34 / 0.46 / 3.80 / 0.65 s |\n| DB / auth-core | 13.45 / 12.89 s |\n| Web UI / server / docs | 15.93 / 0.40 / 0.57 s |\n| Shell suites, sequential | 29.76 s |\n| Global types / OpenAPI / static lint | 4.05 / 0.73 / 12.22 s |\n| Cached web build | 6.86 s |\n\nThe server baseline (57.14 s) included two sandbox socket timeouts and is not a\nhealthy-suite benchmark. Those two files passed outside the sandbox in 1.12 s.\nDo not subtract their individual durations from total wall-clock: workers overlap.\n\nPGlite microprofiling measured about 510 ms for another initialization, 65 ms for\nmigration, 1 ms for minimal seed and 1 ms for close. Restoring the migrated image\nmeasured 108 ms; exporting it measured 22 ms and about 42 MB. This motivates\nsharing an immutable template, not sharing test data. Updated suite and E2E\nmeasurements should come from stage reports, not estimates in this document.\n\nAfter the setup changes, targeted verification measured DB at 5.17 s (including\nan added isolation test), auth-core at 5.15 s, and the six Node logic files at\n0.50 s including npm. Five server files (tasks, remembered, overrides, security,\nscope) passed together in 9.59 s including npm. The complete integration pass\nalso passed; its timings overlapped a cold Docker build and are not an isolated\nbefore/after benchmark.\n\nRepresentative isolated E2E on the same host: the gateway host image took\n16.59 s, dependency install 12.83 s, inner web build 31.56 s, gateway image build\n125.89 s, and lifecycle including its fixtures/cleanup 165.32 s. These phases\nexplain why gateway E2E stays outside the interactive loop. Roles passed alone;\nbootstrap, wrong password, standalone settings and open-panel scenarios also\npassed. In the latter run, per-spec database/bridge readiness was around 2.2 s,\npanel startup including migrations/seed 1.3–1.5 s, and database teardown about\n0.5 s. Browser cases themselves took 0.28–3.4 s. PostgreSQL migration/seed time\nis included in panel startup, not claimed as a separately measured phase.\n","text":"Testing Targeted while developing → affected scope when finishing an ordinary task →broad regression at integration → full/E2E for release or an explicit reason. Finishing a feature increment, answering a user, or handing off a small task isnot an integration milestone. Run the smallest test that protects the changedbehavior. Do not repeat passing checks unless relevant code changed afterward.The agent policy in AGENTS.md governs what to test andwhich tests to run. Keep a test only when its value clearly justifies the timeto create, run and maintain it. Choose a test Every Node workspace accepts a Vitest filename and test-name filter. Prefer afull relative filename; a substring such as tasks can select several files.Combine a filename with -t to avoid importing unrelated files. Check theexecuted test count: Vitest can exit successfully when a name matches no cases. For a module, give its test directory or several filenames. A workspace suiteis the fallback when no reliable narrower selection exists. Types have thesame scope: npm run typecheck --workspace=portta-server, for example. The selector shows the changed files, commands, reasons, recommendations andunmapped paths. It includes staged, unstaged, untracked, renamed and removedfiles. --run refuses a plan with gaps before executing anything. Source fileswithout an exact test fall back to their workspace; shared packages selecttransitive workspace consumers. Configuration and unknown files require anexplicit integration decision. Browser and gateway scenarios are recommendations,never silently started by diff selection. Reviewing a recommendation is stillnecessary: a passing selected scope is not a release certificate. The selector is deliberately conservative. It does not claim that an importgraph covers SQL, shell sourcing, dynamically imported files, or documentationread from disk. No remote cache or external monorepo task system is required. Layers and commands Layer Trigger Command Development A coherent small change Filename/case commands above Affected Several related modules or a changed shared boundary Review test:affected, then --run or explicit suites Integration PR/meaningful merge milestone npm run test:integration E2E Changed real system interaction npm run test:e2e -- --suite lifecycle or --spec roles.spec.ts Release Candidate before publishing npm run test:release Integration runs static lint (including Compose), shell suites, tooling tests,all workspace tests, scoped typechecks, OpenAPI and schema drift checks. Itbuilds core and CLI first so entrypoint smoke uses this checkout's compiledentrypoint. It does not rebuild the panel or start lifecycle containers.Some host-observation assertions are conditional; their skips are recorded.Required static tools (including cloudflared for connector parsing), Node dependencies and selected browser prerequisitesmust be present. A name-filtered Vitest execution with zero cases fails in theorchestrated commands. node tests/run.mjs means integration with an explicit notice. --lint runsstatic checks only, --e2e runs isolated system scenarios, and --releaseruns the complete release gate. Extra or unknown arguments are errors. just test still means broad integration; use the npm filename commands forroutine work. Every orchestrated stage records wall-clock, status, command, skips and a log intest-results/<run>/. Successful stages print a short summary; failed stagesprint their diagnostics once. Browser JSON and failure traces live inapps/web/test-results/. CI uploads these artifacts. Change-to-test matrix The minimum is the directly related case/file, not every example in a row.Widen only for an actual consumer, contract or risk. Integration below appliesto code/configuration PRs; documentation-only PRs check links. A release runs thecomplete release gate once, rather than repeating it for each category. Change Minimum Widen when affected Real-system check before merge when applicable React component UI file for functional behavior Consumers, web types Related browser interaction only if the flow is critical Copy, colors, icons or styles Review or playwright-cli None None Width/layout tweaks Review or playwright-cli None None automatically API Route case Contract, service, authorization; OpenAPI if it can change HTTP dispatcher/browser boundary Service Rule case Routes and callers External integration changed Repository PGlite query/write case Service caller PostgreSQL driver-specific behavior Schema Constraint/cascade test + db:check Repositories PostgreSQL behavior if different Migration Fresh migration/idempotence/data preservation + db:check Repositories Upgrade against PostgreSQL Contract Schema test + OpenAPI if affected Typed consumers and route External API flow Shared core Core file Consumers of the changed export Parity/routing if changed CLI Command test CLI build + entrypoint smoke if packaging changed Relevant command scenario Shell Subject suite + shellcheck TypeScript parity Relevant gateway scenario Installer Install/maintenance fixture Audit, upgrade/refusal cases Disposable install/lifecycle Compose Affected profile/template Consumers and matrix Affected live services Traefik/TCP/TLS Routing derivation/refusal Compose and discovery TCP/TLS with distinct databases Authentication Auth-core/ForwardAuth case API security/principal/scope auth/roles/settings as affected Security Changed refusal/boundary All callers of the policy Corresponding attack/session path Documentation Links Docs collector if behavior changed None automatically Build/packaging Owning build and entrypoint smoke Types, consumers Runtime image as affected Monorepo structure Boundaries and affected compilation Transitive consumers Integration, broader if impact is unclear What the tests protect Keep tests for business rules, refusals, exit codes, authorization boundaries,secret handling, data-loss paths and previous bugs. Do not keep tests for CSSclasses, copy, visual tone, spacing or one-off layout. Simple visual changesare checked with playwright-cli, not a new or existing test file. The role matrix, API origin and scope guards, and browser session tests protectdifferent boundaries. An Engine API fake does not replace the Docker-backedpanel scenario. Bash and TypeScript implementations both ship and still needparity checks. Prefer assertions on observable arguments/results to source strings, labels orclass names. Do not remove a security assertion until another test demonstrablyprotects the same failure mode. Don't use a browser or database just to test apure function; don't replace real constraints/transactions with permissive mocks.Drive polling timers with fake timers where the timer is the subject. Do notreplace real socket or protocol behavior with artificial clocks. The panel has four Vitest projects: logic (pure Node derivations), ui(jsdom/components), server (Node dispatcher), and docs (Node collection).The pools, worker defaults and per-file isolation remain unchanged. Do not turnon isolate: false or concurrent cases over mutable fixtures merely to improvea benchmark. Compare a representative sample before changing workers. PostgreSQL and PGlite createTestDb() creates an independent PostgreSQL/WASM instance on every call.It lazily caches only an immutable, unseeded image produced by the realmigrations, in the current test module. The template client is closed afterexport. No persistent snapshot cache can outlive a schema/version change. Always close instances. Schema tests retain real constraints, foreign keys,transactions and sequences. Migration tests explicitly use fresh instances.Snapshots contain no shared mutable rows. seededDatabase() adds the Project,repository and environments needed by service tests. Avoid redundant seeds. db:check copies schema/config/migrations to a temporary package, runs theactual generator there, compares SQL and metadata, and removes the temporarypackage even on failure. It never edits the checkout's journal or snapshots. E2E ownership, selection and CI Gateway scripts refuse direct execution on a shared daemon. The launcher buildsa disposable host with its own Docker daemon, copies source (without localcredentials/state), and runs selected scenarios there. No host Docker socket orhost checkout is mounted. Cleanup verifies ownership and removes only that hostand its anonymous data volume. This requires Docker support for privilegednested containers; failures are reported, never converted to skips. Browser specs each own a fresh PostgreSQL, a loopback access bridge, an EngineAPI fake and a panel. PostgreSQL publishes no host port. Resource names includea random invocation identity; teardown checks labels. The browser never reusesa pre-existing server. Every invocation builds dependencies and the panel oncebefore workers start. Concurrent E2E builds are refused by a lock. Roles/settings fixtures create their own owner. Auth bootstrap stays a real UIscenario, and its wrong-password test also works alone. Retries get fresh workerresources. Native Playwright filters still work: The manual screenshots/viewports tools use the same owned resources. They areoptional layout checks, not automated tests to create or keep for simple visualchanges. Use playwright-cli for that kind of verification. GitHub Actions validates code PRs with integration and chooses relevant E2Efamilies for auth, routing, lifecycle and harness changes. Pure component changesdo not automatically run browsers. Tags run all E2E families. Publication dependson the reusable validation job; failed prerequisites/tests prevent publication.No schedule, sharding or remote result cache is introduced. Measured costs Audit baseline on 2026-09-05: macOS arm64, 11 available CPUs, 18 GB, Node 22.22.1,Vitest 4.1.11, installed dependencies and existing build caches. These are singleobservations, not universal budgets or cold-install benchmarks. Baseline scope Wall-clock One API case with PGlite 2.10 s One pure helper file under the former UI project 0.77 s Core / contracts / CLI / ForwardAuth 1.34 / 0.46 / 3.80 / 0.65 s DB / auth-core 13.45 / 12.89 s Web UI / server / docs 15.93 / 0.40 / 0.57 s Shell suites, sequential 29.76 s Global types / OpenAPI / static lint 4.05 / 0.73 / 12.22 s Cached web build 6.86 s The server baseline (57.14 s) included two sandbox socket timeouts and is not ahealthy-suite benchmark. Those two files passed outside the sandbox in 1.12 s.Do not subtract their individual durations from total wall-clock: workers overlap. PGlite microprofiling measured about 510 ms for another initialization, 65 ms formigration, 1 ms for minimal seed and 1 ms for close. Restoring the migrated imagemeasured 108 ms; exporting it measured 22 ms and about 42 MB. This motivatessharing an immutable template, not sharing test data. Updated suite and E2Emeasurements should come from stage reports, not estimates in this document. After the setup changes, targeted verification measured DB at 5.17 s (includingan added isolation test), auth-core at 5.15 s, and the six Node logic files at0.50 s including npm. Five server files (tasks, remembered, overrides, security,scope) passed together in 9.59 s including npm. The complete integration passalso passed; its timings overlapped a cold Docker build and are not an isolatedbefore/after benchmark. Representative isolated E2E on the same host: the gateway host image took16.59 s, dependency install 12.83 s, inner web build 31.56 s, gateway image build125.89 s, and lifecycle including its fixtures/cleanup 165.32 s. These phasesexplain why gateway E2E stays outside the interactive loop. Roles passed alone;bootstrap, wrong password, standalone settings and open-panel scenarios alsopassed. In the latter run, per-spec database/bridge readiness was around 2.2 s,panel startup including migrations/seed 1.3–1.5 s, and database teardown about0.5 s. Browser cases themselves took 0.28–3.4 s. PostgreSQL migration/seed timeis included in panel startup, not claimed as a separately measured phase.","headings":[{"id":"testing","text":"Testing","level":1,"line":0},{"id":"choose-a-test","text":"Choose a test","level":2,"line":12},{"id":"layers-and-commands","text":"Layers and commands","level":2,"line":52},{"id":"change-to-test-matrix","text":"Change-to-test matrix","level":2,"line":83},{"id":"what-the-tests-protect","text":"What the tests protect","level":2,"line":113},{"id":"postgresql-and-pglite","text":"PostgreSQL and PGlite","level":2,"line":138},{"id":"e2e-ownership-selection-and-ci","text":"E2E ownership, selection and CI","level":2,"line":160},{"id":"measured-costs","text":"Measured costs","level":2,"line":194}],"kind":"markdown"},{"slug":"publish-cli","title":"Publish the Portta CLI to npm","description":"Publish the Portta CLI to npm with release validation and verification.","source":"docs/development/publish-cli.md","audience":"developer","section":"Development","category":"","url":"/docs/publish-cli","markdown":"# Publish the Portta CLI to npm\n\nGitHub Actions publishes the `portta` workspace to the public npm registry.\nIt creates immutable releases: npm never permits the same package name and\nversion to be published twice.\n\nThe package already declares the public npm registry and public access in\n`packages/cli/package.json`. Its `prepack` hook builds the CLI, compiles the\ndocumentation corpus and copies the runtime assets that `portta setup` needs.\n\n## Before the first publication\n\n1. Create or use the npm account that owns `portta`, and enable two-factor\n authentication for publishing. Confirm the intended account and package:\n\n ```bash\n npm login --registry=https://registry.npmjs.org/\n npm whoami --registry=https://registry.npmjs.org/\n ```\n\n2. Confirm that the unscoped name `portta` is available to that account, or\n that the account already owns it. The first `npm view` can return a 404 when\n no package exists yet; do not attempt a publication until the name and owner\n are settled in npm.\n\n ```bash\n npm view portta --registry=https://registry.npmjs.org/\n ```\n\n3. npm can only add a trusted publisher from an existing package's settings.\n For the first-ever publication of a newly claimed `portta` name, publish\n the tagged release once from a controlled machine using interactive npm\n login and 2FA, then configure the trusted publisher immediately:\n\n ```bash\n git switch --detach vX.Y.Z\n npm ci\n npm run test:release\n npm publish --workspace=portta\n ```\n\n This bootstrap is the only exception; it does not require a permanent\n token. Create the GitHub Release after this publication, or rerun its failed\n workflow afterwards: it detects the existing version and skips publication.\n\n4. On the npm package's **Settings → Publishing access**, add a trusted\n publisher with these exact values:\n\n | npm field | Value |\n | --- | --- |\n | GitHub owner | `fabioassuncao` |\n | Repository | `portta` |\n | Workflow filename | `publish-npm.yaml` |\n | Allowed action | `npm publish` |\n\n This lets `.github/workflows/publish-npm.yaml` exchange its GitHub Actions\n OIDC identity for a short-lived npm credential. No `NPM_TOKEN` is required\n or stored in GitHub. For the strongest policy, configure npm to require 2FA\n and disallow tokens after the first successful trusted publication. Trusted\n publishing generates npm provenance automatically for this public package.\n\n## Publication channels and images\n\n`publish.yaml` resolves one identity, validates once, then calls the reusable\nnpm and GHCR workflows. The same identity is applied to the panel\n(`ghcr.io/<owner>/portta`) and CLI (`ghcr.io/<owner>/portta-cli`) image.\n\n| Trigger | Generated version | Image tags for both images | npm dist-tag |\n| --- | --- | --- | --- |\n| Published GitHub Release `vX.Y.Z` | `X.Y.Z` | `X.Y.Z`, `sha-<short-sha>` | `latest` |\n| Push to `main` | `0.0.0-edge.<run>.<sha>` | `edge`, `edge-<short-sha>`, `sha-<short-sha>` | `next` |\n| Push to `develop` | `0.0.0-develop.<run>.<sha>` | `develop`, `develop-<short-sha>`, `sha-<short-sha>` | `dev` |\n\nThe `v` prefix belongs only to the GitHub Release name. Docker and npm use the\nnormalized value without it: `v1.2.3` produces `portta:1.2.3`,\n`portta-cli:1.2.3`, and `portta@1.2.3`. `edge` and `develop` are mutable\nconvenience pointers; use a SHA tag for a reproducible deployment. Development\nbuilds never write a release tag.\n\n`VERSION` is not source-controlled. The workflows generate it only in their\nephemeral checkout, and the CLI build embeds it into its packaged runtime. A\nsource checkout uses the CLI package version as its local development identity.\n\n## Publish an official release\n\n1. Merge the release-ready changes, including `CHANGELOG.md`, then check out\n the release branch and update it. Releases normally come from `main`.\n\n ```bash\n git switch main\n git pull --ff-only origin main\n git status --short\n ```\n\n Continue only when the last command prints nothing. The version lifecycle\n intentionally refuses a dirty worktree.\n\n2. Run the release gate. This is the one place where the full release suite is\n required rather than the ordinary targeted checks.\n\n ```bash\n npm run test:release\n ```\n\n3. Create a published GitHub Release named `vX.Y.Z`. This is the release\n control point: the workflow rejects a non-SemVer tag, generates the runtime\n version, and publishes both images and the matching npm package.\n\n4. Wait for **Publish Portta** to succeed. It reuses the repository's\n validation workflow, then runs CLI typecheck, tests, build, `npm pack\n --dry-run`, creates a tarball, checks for `dist/cli.js`,\n `dist/documentation.json` and `dist/runtime/`, and installs that tarball in\n a temporary directory before executing `portta --version`.\n\n5. Verify the registry record and execute the package npm served. Substitute\n the release version:\n\n ```bash\n npm view portta@0.8.1 version dist-tags --json\n npx --yes portta@0.8.1 --version\n ```\n\n The second command must print `0.8.1`. Verify the npm package page also\n shows the intended repository and public access.\n\n## Idempotency and troubleshooting\n\nA published npm version cannot be reused, including after an unpublish. For a\nbad release, publish a corrected higher version and use npm deprecation to\nguide users away from the affected version when appropriate. Do not make an\ninstaller point at an unpublished or missing image tag. The workflow queries\n`portta@<version>` before publishing; a rerun that finds exactly that version\nlogs the result and skips `npm publish`.\n\nIf trusted publishing fails, check that the npm package is owned by the npm\naccount, the GitHub owner/repository/workflow filename match the table above,\nand the workflow has `id-token: write`. Do not add a permanent token as a\nfirst response. A token is only a fallback when npm cannot support trusted\npublishing for the package; use a granular, publish-scoped secret and document\nthe reason before adding it.\n\nFor a version mismatch, correct the GitHub Release tag rather than editing a\ncheckout or a runner. The generated file is an artefact, never release state.\n\nSee npm's documentation for [publishing a package](https://docs.npmjs.com/cli/v11/commands/npm-publish/), [two-factor authentication](https://docs.npmjs.com/about-two-factor-authentication/), and [trusted publishing](https://docs.npmjs.com/trusted-publishers/).\n","text":"Publish the Portta CLI to npm GitHub Actions publishes the portta workspace to the public npm registry.It creates immutable releases: npm never permits the same package name andversion to be published twice. The package already declares the public npm registry and public access inpackages/cli/package.json. Its prepack hook builds the CLI, compiles thedocumentation corpus and copies the runtime assets that portta setup needs. Before the first publication Create or use the npm account that owns portta, and enable two-factorauthentication for publishing. Confirm the intended account and package: Confirm that the unscoped name portta is available to that account, orthat the account already owns it. The first npm view can return a 404 whenno package exists yet; do not attempt a publication until the name and ownerare settled in npm. npm can only add a trusted publisher from an existing package's settings.For the first-ever publication of a newly claimed portta name, publishthe tagged release once from a controlled machine using interactive npmlogin and 2FA, then configure the trusted publisher immediately: This bootstrap is the only exception; it does not require a permanenttoken. Create the GitHub Release after this publication, or rerun its failedworkflow afterwards: it detects the existing version and skips publication. On the npm package's Settings → Publishing access, add a trustedpublisher with these exact values: npm field Value GitHub owner fabioassuncao Repository portta Workflow filename publish-npm.yaml Allowed action npm publish This lets .github/workflows/publish-npm.yaml exchange its GitHub ActionsOIDC identity for a short-lived npm credential. No NPM_TOKEN is requiredor stored in GitHub. For the strongest policy, configure npm to require 2FAand disallow tokens after the first successful trusted publication. Trustedpublishing generates npm provenance automatically for this public package. Publication channels and images publish.yaml resolves one identity, validates once, then calls the reusablenpm and GHCR workflows. The same identity is applied to the panel(ghcr.io/<owner>/portta) and CLI (ghcr.io/<owner>/portta-cli) image. Trigger Generated version Image tags for both images npm dist-tag Published GitHub Release vX.Y.Z X.Y.Z X.Y.Z, sha-<short-sha> latest Push to main 0.0.0-edge.<run>.<sha> edge, edge-<short-sha>, sha-<short-sha> next Push to develop 0.0.0-develop.<run>.<sha> develop, develop-<short-sha>, sha-<short-sha> dev The v prefix belongs only to the GitHub Release name. Docker and npm use thenormalized value without it: v1.2.3 produces portta:1.2.3,portta-cli:1.2.3, and portta@1.2.3. edge and develop are mutableconvenience pointers; use a SHA tag for a reproducible deployment. Developmentbuilds never write a release tag. VERSION is not source-controlled. The workflows generate it only in theirephemeral checkout, and the CLI build embeds it into its packaged runtime. Asource checkout uses the CLI package version as its local development identity. Publish an official release Merge the release-ready changes, including CHANGELOG.md, then check outthe release branch and update it. Releases normally come from main. Continue only when the last command prints nothing. The version lifecycleintentionally refuses a dirty worktree. Run the release gate. This is the one place where the full release suite isrequired rather than the ordinary targeted checks. Create a published GitHub Release named vX.Y.Z. This is the releasecontrol point: the workflow rejects a non-SemVer tag, generates the runtimeversion, and publishes both images and the matching npm package. Wait for Publish Portta to succeed. It reuses the repository'svalidation workflow, then runs CLI typecheck, tests, build, npm pack --dry-run, creates a tarball, checks for dist/cli.js,dist/documentation.json and dist/runtime/, and installs that tarball ina temporary directory before executing portta --version. Verify the registry record and execute the package npm served. Substitutethe release version: The second command must print 0.8.1. Verify the npm package page alsoshows the intended repository and public access. Idempotency and troubleshooting A published npm version cannot be reused, including after an unpublish. For abad release, publish a corrected higher version and use npm deprecation toguide users away from the affected version when appropriate. Do not make aninstaller point at an unpublished or missing image tag. The workflow queriesportta@<version> before publishing; a rerun that finds exactly that versionlogs the result and skips npm publish. If trusted publishing fails, check that the npm package is owned by the npmaccount, the GitHub owner/repository/workflow filename match the table above,and the workflow has id-token: write. Do not add a permanent token as afirst response. A token is only a fallback when npm cannot support trustedpublishing for the package; use a granular, publish-scoped secret and documentthe reason before adding it. For a version mismatch, correct the GitHub Release tag rather than editing acheckout or a runner. The generated file is an artefact, never release state. See npm's documentation for publishing a package, two-factor authentication, and trusted publishing.","headings":[{"id":"publish-the-portta-cli-to-npm","text":"Publish the Portta CLI to npm","level":1,"line":0},{"id":"before-the-first-publication","text":"Before the first publication","level":2,"line":10},{"id":"publication-channels-and-images","text":"Publication channels and images","level":2,"line":61},{"id":"publish-an-official-release","text":"Publish an official release","level":2,"line":83},{"id":"idempotency-and-troubleshooting","text":"Idempotency and troubleshooting","level":2,"line":125}],"kind":"markdown"},{"slug":"design-system","title":"Design system","description":"Use the panel tokens, components and accessibility conventions.","source":"docs/development/design-system.md","audience":"developer","section":"Development","category":"","url":"/docs/design-system","markdown":"# Design system\n\nThe panel is used for hours at a time, by people who are looking for one row\namong fifty. Its visual language is built for that: a near-black canvas, four\ngraphite surfaces, hairline borders, one lavender accent, small type and small\ncontrols. Hierarchy comes from surface and edge, not from shadow or colour.\n\nThe reference is Linear's product interface: its density, its restraint with\ncolour and its keyboard-first interaction. The identity, the tokens and the\ncomponents are Portta's own. `DESIGN.md` at the repository root is the\nmachine-readable summary of the same system.\n\nEverything here is implemented in `apps/web/app/globals.css` (tokens) and\n`apps/web/components/ui/` (primitives). When a value in this page and a\nvalue in those files disagree, the file is right and this page needs a fix.\n\n## Principles\n\n- **Surface over shadow.** A card is the page's own colour with a hairline\n around it. The only shadows are under a menu and under a dialog.\n- **One accent, rarely.** Lavender marks the primary action, keyboard focus, a\n selected control and a live link. It never fills a page or a card.\n- **Colour means something.** `ok`, `warn`, `danger`, `info` and `agent` say\n what a thing is doing. A colour that decorates is a colour the eye learns to\n ignore, and then the one that matters is missed too.\n- **Small, dense, aligned.** Controls are 28px tall, rows are 36px, text is\n 13px. Space is spent between groups, not inside them.\n- **Progressive disclosure.** A row shows what a glance needs; the rest is one\n hover, one `…` menu or one keystroke away.\n- **Keyboard first.** `⌘K` opens every place and every action; `[` folds the\n sidebar; focus is unmistakable.\n- **The same in both themes.** Components use semantic tokens only and never\n know which theme is active.\n\n## Tokens\n\nAll tokens are CSS custom properties named `--portta-*`, declared once for\nlight on `:root` and once for dark on `.dark`, and mapped into Tailwind\nthrough `@theme inline` so they are used as utilities: `bg-surface-2`,\n`text-subtle`, `border-line`.\n\n### Colour\n\n| Token | Utility | Use |\n| --- | --- | --- |\n| `bg` | `bg-bg` | The canvas: the sidebar and what shows behind the main panel. |\n| `surface` | `bg-surface` | The main panel, cards, dialogs, inputs. |\n| `surface-2` | `bg-surface-2` | A band inside a card, a board column, an input at rest in a dense form. |\n| `surface-3` | `bg-surface-3` | A chip, a badge, a pressed control. |\n| `surface-4` | `bg-surface-4` | The strongest neutral fill; rare. |\n| `overlay` | `bg-overlay` | A menu, a popover, a toast: one step above the surface it floats over. |\n| `line-subtle` / `line` / `line-strong` | `border-line-*`, `divide-line-*` | Row dividers / card edges and inputs / a hovered input, a secondary button. |\n| `ink` / `muted` / `subtle` / `faint` | `text-*` | Titles and values / body and labels / metadata / placeholders and disabled. |\n| `accent` / `accent-hover` / `accent-fg` | `bg-accent`, `text-accent` | The primary action, a link, a focus ring; its hover; text on it. |\n| `selection` | `bg-selection` | A selected row or region: the accent at 10–14%. |\n| `fill` / `fill-strong` | `bg-fill`, `bg-fill-strong` | A hover and an active state made from the text colour, so they lift the same way on every surface. |\n| `focus` | outline | The keyboard focus ring (the accent). |\n| `scrim` | `bg-scrim` | What sits behind a dialog or drawer. |\n| `tooltip` / `tooltip-fg` | | The one inverted surface. |\n| `ok` / `warn` / `danger` / `info` / `agent` | `text-ok`, `bg-ok/12`… | Semantic tones. See below. |\n\nRules: never write a hex, `oklch()` or `rgb()` in a component. Never use a\nTailwind palette colour (`zinc-*`, `emerald-*`). A new colour is a new token,\nadded to both themes at once.\n\n### Semantic tones\n\n`lib/tone.ts` is the single source of the class names for a tone: `toneBg`\n(a dot or bar), `toneText` (an icon or a word), `toneSoft` (a badge tint),\n`toneWash` (a callout background), `toneBorder` (a callout edge). Every dot,\nbadge and indicator reads from it.\n\n| Tone | Meaning |\n| --- | --- |\n| `neutral` | structure; anything the eye should skip |\n| `accent` | selected, primary, live |\n| `ok` | running, healthy, done, connected |\n| `warn` | degraded, stale, pending, restarting, starting |\n| `danger` | failed, unhealthy, blocked, destructive |\n| `info` | in progress; informational |\n| `agent` | an agent is doing this (the one product-specific tone) |\n\nInfrastructure states are drawn with `StatusIndicator` (`● running`), never\nwith a filled badge: the dot carries the colour, the word stays quiet.\n\n### Typography\n\nInter Variable for the interface, JetBrains Mono for anything a terminal\nwould show. Both are bundled (`@fontsource-variable/*`), so the panel looks\nthe same on a host with no fonts of its own.\n\n| Utility | Size / line | Use |\n| --- | --- | --- |\n| `text-2xs` | 11 / 16 | eyebrows, chips, ids, timestamps |\n| `text-xs` | 12 / 18 | metadata, table headers, hints, badges |\n| `text-sm` | 13 / 20 | rows, controls, body of a card, menu items |\n| `text-base` | 14 / 22 | the page body, dialog titles, the command input |\n| `text-lg` | 16 / 24 | page titles |\n| `text-xl` | 18 / 24 | the title of a task or a project |\n| `text-2xl` | 22 / 28 | a number on a stat tile, at most |\n\nWeights: 400 for text, 500 for labels, titles of rows and buttons, 600 for\npage titles. Large sizes carry a little negative tracking (set on the token).\nNumbers that change in place use `tabular-nums`.\n\nMono is for paths, ports, URLs, hashes, branches, env keys, commands, ids and\nlogs, through `Mono`, `CodeChip`, `CommandRow` and `Pre` in\n`components/copy.tsx`. It is never used for prose or labels.\n\n### Spacing, radius, elevation\n\nSpacing is Tailwind's 4px scale. Inside a card: `px-3`, `py-2`. Between cards\nand sections: `gap-4`. A page's main column: `px-6 py-5`.\n\n| Utility | Radius | Use |\n| --- | --- | --- |\n| `rounded-xs` | 3px | kbd, tiny chips |\n| `rounded-sm` | 4px | badges, menu items, inline code |\n| `rounded-md` | 6px | buttons, inputs, cards on a board, tooltips |\n| `rounded-lg` | 8px | cards, menus, popovers, the main panel, nav items |\n| `rounded-xl` | 12px | dialogs, the command palette |\n| `rounded-full` | | dots, pills (labels, status chips), the switch |\n\nElevation has two steps: `shadow-overlay` under a menu, popover, tooltip or\ntoast; `shadow-modal` under a dialog or drawer. Cards have none.\n\n### Motion\n\nEverything is 100–180ms with an ease-out. Menus and popovers pop in\n(`animate-pop-in`), dialogs scale from 98.5% (`animate-dialog-in`), drawers\nslide in from the right, toasts rise 6px. Nothing animates out. Hover and\nfocus changes are `transition-colors duration-100`. `prefers-reduced-motion`\nturns all of it off.\n\n## Themes\n\nBoth themes exist and share every token name. Dark is the reference; light is\nderived with the same surface ladder and the same accent. The choice is\n`light`, `dark` or `system` (`lib/theme.ts`); only an explicit choice is\nstored, so a person who never chose keeps following the OS. The pre-paint\nscript in `apps/web/lib/theme-script.ts` (panel) and `apps/auth/ui/index.html`\n(login) applies the class before the first render. They share `portta-theme`\nand must stay in sync.\n\n## Interaction states\n\n| State | How it looks |\n| --- | --- |\n| hover | `bg-fill` on a row or a ghost control; `border-line-strong` on an input or a card that opens |\n| focus (keyboard) | a 2px accent outline, 1px outside the element (`:focus-visible`, or the `focus-ring` / `focus-ring-inset` classes where the outline was reset) |\n| active / pressed | one surface step darker than hover |\n| selected | `bg-fill-strong text-ink` for a nav item, a segment, a choice; `bg-selection` for a row |\n| open (`data-state=open`) | the trigger keeps its hover fill |\n| checked | the accent fill (checkbox, switch) |\n| disabled | 50% opacity, no pointer |\n| invalid | `border-danger` and a danger ring (`aria-invalid`) |\n| read-only | `bg-surface-2` |\n\n## Components\n\nAll in `apps/web/components/ui/` unless noted.\n\n- **Button** — variants `primary`, `default` (= `secondary`), `subtle`,\n `ghost`, `outline`, `danger`, `link`; sizes `xs` (24), `sm` (28), `md` (32),\n `icon`, `icon-sm`, `icon-xs`, `icon-md`. `sm` is the working size; `md` is\n for a page's one main action, on every page. Icons inside are sized by the\n button: never give one a `size-*` of its own.\n `asChild` renders a link with the same styling. `busy` shows a spinner and\n disables.\n- **Badge** — soft tint, no border; `tone`, `size` (`sm` 20px, `md` 24px),\n `shape` (`square` | `pill`), `dot`, `icon`. For counts, categories, scope\n and ownership. **StatusDot** and **StatusIndicator** (dot + word) are for\n states.\n- **Input, Select, Textarea, Checkbox, Label, Field** — one border language\n in four states; `size=\"sm\"` (28px) in toolbars, `md` (32px) in forms;\n `mono` for technical values. `Field` binds a label, a hint and an error to\n the control and offers `inline` for a settings row. In a toolbar, use the\n toolbar's own controls: `ToolbarSearch` (`w-64`), `ToolbarSelect` (`w-36`,\n `width=\"lg\"` = `w-40` for a sentence-long first option) and `ToolbarCheck`\n (a checkbox with its label at 28px), so a search box is the same search box\n on every page.\n- **Card** with `CardHeader`, `CardBody`, `CardFooter`, `CardSection` — a\n hairline, no shadow; a 36px header; a section band for grouped rows.\n- **Dialog, Drawer, ConfirmDialog** — share `Scrim`, `ModalHeader` and\n `ModalFooter`; sizes `sm`/`md`/`lg`; the confirmation names the impact and\n can require typing the name of what is destroyed.\n- **Menu, Popover, Tooltip, Toast, CommandPalette** — one floating surface\n (`surfaces.ts`: `overlaySurface`, `overlayItem`, `overlayLabel`), one item\n height (28px), one hover (`bg-fill`). Menu items take `icon`, `hint` and\n `shortcut`; radio and checkbox items exist; submenus exist. The palette\n (`⌘K`) lists every section, every project, the actions of the current page\n and the preferences.\n- **Tabs** — URL-driven, 36px, an accent underline, the same weight whether\n selected or not so nothing shifts. **Segmented** — a lifted segment for a\n view switch or a two-way scope filter. With an icon, the label hides below\n `sm` and the radio keeps its `aria-label`. Icons: `LayoutGrid` (cards),\n `Columns3` (board), `Table2` (table).\n- **Table, DataTable** — `thClass`/`tdClass`/`trClass` are shared so a plain\n table and the data table cannot drift. Headers are 12px sentence case, rows\n are dense, hover is a tint, selection is `bg-selection`. Which columns show\n and which one sorts is a `useTableArrangement(storageKey)` handle\n (`components/ui/table-arrangement.tsx`): the page holds it, passes it to\n the `DataTable` as `arrangement`, and renders `ColumnsMenu` in the toolbar\n above (or a card's header). A `DataTable` without a handle keeps its own\n and offers the menu in a band of its own.\n- **Kbd, Shortcut** — keys as keys, in menus, tooltips and the palette.\n- **Timeline, Breadcrumb, Switch, Skeleton** — as their names say.\n- **Shell pieces** (`components/shell-bits.tsx`) — `PageHeader` (breadcrumb,\n title, description, `meta`, `actions`), `Toolbar`, `ViewToolbar`,\n `ToolbarSearch`, `ToolbarSelect`, `ToolbarCheck`, `SectionHeader`,\n `Eyebrow`, `NoValue`, `Callout`, `ErrorBox`, `Empty`, `Loading`,\n `Skeleton*`, `StatTile`, `KeyValue`.\n- **Host** (`components/host-summary.tsx`) — `HostHeader` (who the machine\n is, and its state), `HostReadings` (every measurement it reports, as one\n strip).\n- **Technical values** (`components/copy.tsx`) — `Mono kind=path|port|url|sha|branch|command|id|host`,\n `CodeChip`, `CommandRow`, `Pre`, `CopyButton`, `AddressLine`.\n\n## Layout\n\n```text\nAppShell\n├── ConnectionBanner / ApplyBar full-width strips\n├── Sidebar (canvas) brand · ⌘K · groups of nav links · controls\n└── Main panel (surface, hairline) PageHeader → content\n └── DetailPanel a task: content + a 17rem property column\nCommandPalette · Dialogs · Toasts portalled\n```\n\nThe sidebar is 224px, or 48px when folded; on a narrow screen it becomes a\nrow above the content. Nav items are links, 28px tall, with the active one\nlifted by `bg-fill-strong`. The main panel is inset from the canvas with a\nhairline, so the content is what the eye lands on.\n\nA page starts with `PageHeader`, then the row of controls, then the content.\nTwo rows, with fixed jobs:\n\n```text\n┌─────────────────────────────────────────────────────────────┐\n│ Title + description [+ Primary verb] │ PageHeader.actions (md)\n├─────────────────────────────────────────────────────────────┤\n│ [Cards|Table] [search] [filters…] [Columns] [badge] │ ViewToolbar (sm)\n├─────────────────────────────────────────────────────────────┤\n│ Content: cards / board / table │\n│ the row above does not move when this changes │\n└─────────────────────────────────────────────────────────────┘\n```\n\n- `PageHeader.actions` is the page verb (create), at `md`. Never a filter,\n never a view switcher.\n- `ViewToolbar` is every control of the list, in one row, in one place:\n `Segmented` first when there is a view to switch, then the filters that\n shape the rows (search first), then what belongs at the right edge in\n `trailing`: the `ColumnsMenu` while the view is a table, a read-only badge.\n Switching cards to a table changes what is under the row, never the row.\n A page with filters and no view switch (Services, Docker, Environments,\n Tokens, Audit) uses the same row without a switcher. Nested surfaces (a\n project Tasks tab) use the same row; they do not grow a second header.\n- A table inside a card that is not the page (a Docker group) keeps its\n column menu in the card's header, beside the card's title.\n\nPages do not invent their own headers, paddings or section titles. The one\nexception is the Overview, which has no visible title: its subject is the\nhost, so it opens with `HostHeader` (`components/host-summary.tsx`) — the\nmachine's name and kind where a title would be, its facts under them, the\ngateway's and the host's state beside them — and keeps the route name as a\nscreen-reader-only `h1`.\n\nA list is a table (`DataTable`) or a list of rows (`TaskRow`); a board is\ncolumns of `TaskCard`. A detail is content on the left and properties on the\nright (`PropertyRow`), the way a task page does it.\n\n## Accessibility\n\n- Every control has a name; icon-only controls carry `aria-label` and a\n tooltip. The viewport check (`apps/web/e2e/viewports.mjs`) fails on a\n nameless control.\n- Focus is visible everywhere. Overlays that reset the outline paint their\n own highlight on the focused item.\n- Colour is never the only signal: a state has a word or an accessible name\n beside its dot.\n- Text contrast: `subtle` on `surface` is above 4.5:1 in both themes;\n `faint` is reserved for placeholders and disabled text.\n- Dialogs trap focus and close on Escape; the command palette is a combobox\n with a listbox.\n\n## Do / don't\n\n- Do use `StatusIndicator` for a state. Don't use a filled badge for it.\n- Do put one primary button on a page. Don't put two.\n- Do use `Callout` for a notice. Don't hand-roll `border-warn/40 bg-warn/5`.\n- Do use `text-2xs`. Don't write `text-[11px]`.\n- Do use `bg-fill-strong text-ink` for a selected item. Don't use\n `bg-accent/12 text-accent`.\n- Do use `Mono kind=\"path\"`. Don't write `font-mono text-xs text-muted` by\n hand.\n- Do keep headers 36px and rows 36px. Don't add a padding step because a\n page \"felt tight\".\n- Do add a token when you need a colour. Don't write a hex.\n- Do put a view switcher in `ViewToolbar`, first, as `Segmented`. Don't put\n it in `PageHeader.actions` or roll a pair of buttons.\n- Do keep the toolbar where it is when the view changes. Don't move it into\n the table card, and don't put a filter beside the page verb.\n","text":"Design system The panel is used for hours at a time, by people who are looking for one rowamong fifty. Its visual language is built for that: a near-black canvas, fourgraphite surfaces, hairline borders, one lavender accent, small type and smallcontrols. Hierarchy comes from surface and edge, not from shadow or colour. The reference is Linear's product interface: its density, its restraint withcolour and its keyboard-first interaction. The identity, the tokens and thecomponents are Portta's own. DESIGN.md at the repository root is themachine-readable summary of the same system. Everything here is implemented in apps/web/app/globals.css (tokens) andapps/web/components/ui/ (primitives). When a value in this page and avalue in those files disagree, the file is right and this page needs a fix. Principles Surface over shadow. A card is the page's own colour with a hairlinearound it. The only shadows are under a menu and under a dialog. One accent, rarely. Lavender marks the primary action, keyboard focus, aselected control and a live link. It never fills a page or a card. Colour means something. ok, warn, danger, info and agent saywhat a thing is doing. A colour that decorates is a colour the eye learns toignore, and then the one that matters is missed too. Small, dense, aligned. Controls are 28px tall, rows are 36px, text is13px. Space is spent between groups, not inside them. Progressive disclosure. A row shows what a glance needs; the rest is onehover, one … menu or one keystroke away. Keyboard first. ⌘K opens every place and every action; [ folds thesidebar; focus is unmistakable. The same in both themes. Components use semantic tokens only and neverknow which theme is active. Tokens All tokens are CSS custom properties named --portta-*, declared once forlight on :root and once for dark on .dark, and mapped into Tailwindthrough @theme inline so they are used as utilities: bg-surface-2,text-subtle, border-line. Colour Token Utility Use bg bg-bg The canvas: the sidebar and what shows behind the main panel. surface bg-surface The main panel, cards, dialogs, inputs. surface-2 bg-surface-2 A band inside a card, a board column, an input at rest in a dense form. surface-3 bg-surface-3 A chip, a badge, a pressed control. surface-4 bg-surface-4 The strongest neutral fill; rare. overlay bg-overlay A menu, a popover, a toast: one step above the surface it floats over. line-subtle / line / line-strong border-line-*, divide-line-* Row dividers / card edges and inputs / a hovered input, a secondary button. ink / muted / subtle / faint text-* Titles and values / body and labels / metadata / placeholders and disabled. accent / accent-hover / accent-fg bg-accent, text-accent The primary action, a link, a focus ring; its hover; text on it. selection bg-selection A selected row or region: the accent at 10–14%. fill / fill-strong bg-fill, bg-fill-strong A hover and an active state made from the text colour, so they lift the same way on every surface. focus outline The keyboard focus ring (the accent). scrim bg-scrim What sits behind a dialog or drawer. tooltip / tooltip-fg The one inverted surface. ok / warn / danger / info / agent text-ok, bg-ok/12… Semantic tones. See below. Rules: never write a hex, oklch() or rgb() in a component. Never use aTailwind palette colour (zinc-*, emerald-*). A new colour is a new token,added to both themes at once. Semantic tones lib/tone.ts is the single source of the class names for a tone: toneBg(a dot or bar), toneText (an icon or a word), toneSoft (a badge tint),toneWash (a callout background), toneBorder (a callout edge). Every dot,badge and indicator reads from it. Tone Meaning neutral structure; anything the eye should skip accent selected, primary, live ok running, healthy, done, connected warn degraded, stale, pending, restarting, starting danger failed, unhealthy, blocked, destructive info in progress; informational agent an agent is doing this (the one product-specific tone) Infrastructure states are drawn with StatusIndicator (● running), neverwith a filled badge: the dot carries the colour, the word stays quiet. Typography Inter Variable for the interface, JetBrains Mono for anything a terminalwould show. Both are bundled (@fontsource-variable/*), so the panel looksthe same on a host with no fonts of its own. Utility Size / line Use text-2xs 11 / 16 eyebrows, chips, ids, timestamps text-xs 12 / 18 metadata, table headers, hints, badges text-sm 13 / 20 rows, controls, body of a card, menu items text-base 14 / 22 the page body, dialog titles, the command input text-lg 16 / 24 page titles text-xl 18 / 24 the title of a task or a project text-2xl 22 / 28 a number on a stat tile, at most Weights: 400 for text, 500 for labels, titles of rows and buttons, 600 forpage titles. Large sizes carry a little negative tracking (set on the token).Numbers that change in place use tabular-nums. Mono is for paths, ports, URLs, hashes, branches, env keys, commands, ids andlogs, through Mono, CodeChip, CommandRow and Pre incomponents/copy.tsx. It is never used for prose or labels. Spacing, radius, elevation Spacing is Tailwind's 4px scale. Inside a card: px-3, py-2. Between cardsand sections: gap-4. A page's main column: px-6 py-5. Utility Radius Use rounded-xs 3px kbd, tiny chips rounded-sm 4px badges, menu items, inline code rounded-md 6px buttons, inputs, cards on a board, tooltips rounded-lg 8px cards, menus, popovers, the main panel, nav items rounded-xl 12px dialogs, the command palette rounded-full dots, pills (labels, status chips), the switch Elevation has two steps: shadow-overlay under a menu, popover, tooltip ortoast; shadow-modal under a dialog or drawer. Cards have none. Motion Everything is 100–180ms with an ease-out. Menus and popovers pop in(animate-pop-in), dialogs scale from 98.5% (animate-dialog-in), drawersslide in from the right, toasts rise 6px. Nothing animates out. Hover andfocus changes are transition-colors duration-100. prefers-reduced-motionturns all of it off. Themes Both themes exist and share every token name. Dark is the reference; light isderived with the same surface ladder and the same accent. The choice islight, dark or system (lib/theme.ts); only an explicit choice isstored, so a person who never chose keeps following the OS. The pre-paintscript in apps/web/lib/theme-script.ts (panel) and apps/auth/ui/index.html(login) applies the class before the first render. They share portta-themeand must stay in sync. Interaction states State How it looks hover bg-fill on a row or a ghost control; border-line-strong on an input or a card that opens focus (keyboard) a 2px accent outline, 1px outside the element (:focus-visible, or the focus-ring / focus-ring-inset classes where the outline was reset) active / pressed one surface step darker than hover selected bg-fill-strong text-ink for a nav item, a segment, a choice; bg-selection for a row open (data-state=open) the trigger keeps its hover fill checked the accent fill (checkbox, switch) disabled 50% opacity, no pointer invalid border-danger and a danger ring (aria-invalid) read-only bg-surface-2 Components All in apps/web/components/ui/ unless noted. Button — variants primary, default (= secondary), subtle,ghost, outline, danger, link; sizes xs (24), sm (28), md (32),icon, icon-sm, icon-xs, icon-md. sm is the working size; md isfor a page's one main action, on every page. Icons inside are sized by thebutton: never give one a size-* of its own.asChild renders a link with the same styling. busy shows a spinner anddisables. Badge — soft tint, no border; tone, size (sm 20px, md 24px),shape (square | pill), dot, icon. For counts, categories, scopeand ownership. StatusDot and StatusIndicator (dot + word) are forstates. Input, Select, Textarea, Checkbox, Label, Field — one border languagein four states; size=\"sm\" (28px) in toolbars, md (32px) in forms;mono for technical values. Field binds a label, a hint and an error tothe control and offers inline for a settings row. In a toolbar, use thetoolbar's own controls: ToolbarSearch (w-64), ToolbarSelect (w-36,width=\"lg\" = w-40 for a sentence-long first option) and ToolbarCheck(a checkbox with its label at 28px), so a search box is the same search boxon every page. Card with CardHeader, CardBody, CardFooter, CardSection — ahairline, no shadow; a 36px header; a section band for grouped rows. Dialog, Drawer, ConfirmDialog — share Scrim, ModalHeader andModalFooter; sizes sm/md/lg; the confirmation names the impact andcan require typing the name of what is destroyed. Menu, Popover, Tooltip, Toast, CommandPalette — one floating surface(surfaces.ts: overlaySurface, overlayItem, overlayLabel), one itemheight (28px), one hover (bg-fill). Menu items take icon, hint andshortcut; radio and checkbox items exist; submenus exist. The palette(⌘K) lists every section, every project, the actions of the current pageand the preferences. Tabs — URL-driven, 36px, an accent underline, the same weight whetherselected or not so nothing shifts. Segmented — a lifted segment for aview switch or a two-way scope filter. With an icon, the label hides belowsm and the radio keeps its aria-label. Icons: LayoutGrid (cards),Columns3 (board), Table2 (table). Table, DataTable — thClass/tdClass/trClass are shared so a plaintable and the data table cannot drift. Headers are 12px sentence case, rowsare dense, hover is a tint, selection is bg-selection. Which columns showand which one sorts is a useTableArrangement(storageKey) handle(components/ui/table-arrangement.tsx): the page holds it, passes it tothe DataTable as arrangement, and renders ColumnsMenu in the toolbarabove (or a card's header). A DataTable without a handle keeps its ownand offers the menu in a band of its own. Kbd, Shortcut — keys as keys, in menus, tooltips and the palette. Timeline, Breadcrumb, Switch, Skeleton — as their names say. Shell pieces (components/shell-bits.tsx) — PageHeader (breadcrumb,title, description, meta, actions), Toolbar, ViewToolbar,ToolbarSearch, ToolbarSelect, ToolbarCheck, SectionHeader,Eyebrow, NoValue, Callout, ErrorBox, Empty, Loading,Skeleton*, StatTile, KeyValue. Host (components/host-summary.tsx) — HostHeader (who the machineis, and its state), HostReadings (every measurement it reports, as onestrip). Technical values (components/copy.tsx) — Mono kind=path|port|url|sha|branch|command|id|host,CodeChip, CommandRow, Pre, CopyButton, AddressLine. Layout The sidebar is 224px, or 48px when folded; on a narrow screen it becomes arow above the content. Nav items are links, 28px tall, with the active onelifted by bg-fill-strong. The main panel is inset from the canvas with ahairline, so the content is what the eye lands on. A page starts with PageHeader, then the row of controls, then the content.Two rows, with fixed jobs: PageHeader.actions is the page verb (create), at md. Never a filter,never a view switcher. ViewToolbar is every control of the list, in one row, in one place:Segmented first when there is a view to switch, then the filters thatshape the rows (search first), then what belongs at the right edge intrailing: the ColumnsMenu while the view is a table, a read-only badge.Switching cards to a table changes what is under the row, never the row.A page with filters and no view switch (Services, Docker, Environments,Tokens, Audit) uses the same row without a switcher. Nested surfaces (aproject Tasks tab) use the same row; they do not grow a second header. A table inside a card that is not the page (a Docker group) keeps itscolumn menu in the card's header, beside the card's title. Pages do not invent their own headers, paddings or section titles. The oneexception is the Overview, which has no visible title: its subject is thehost, so it opens with HostHeader (components/host-summary.tsx) — themachine's name and kind where a title would be, its facts under them, thegateway's and the host's state beside them — and keeps the route name as ascreen-reader-only h1. A list is a table (DataTable) or a list of rows (TaskRow); a board iscolumns of TaskCard. A detail is content on the left and properties on theright (PropertyRow), the way a task page does it. Accessibility Every control has a name; icon-only controls carry aria-label and atooltip. The viewport check (apps/web/e2e/viewports.mjs) fails on anameless control. Focus is visible everywhere. Overlays that reset the outline paint theirown highlight on the focused item. Colour is never the only signal: a state has a word or an accessible namebeside its dot. Text contrast: subtle on surface is above 4.5:1 in both themes;faint is reserved for placeholders and disabled text. Dialogs trap focus and close on Escape; the command palette is a comboboxwith a listbox. Do / don't Do use StatusIndicator for a state. Don't use a filled badge for it. Do put one primary button on a page. Don't put two. Do use Callout for a notice. Don't hand-roll border-warn/40 bg-warn/5. Do use text-2xs. Don't write text-[11px]. Do use bg-fill-strong text-ink for a selected item. Don't usebg-accent/12 text-accent. Do use Mono kind=\"path\". Don't write font-mono text-xs text-muted byhand. Do keep headers 36px and rows 36px. Don't add a padding step because apage \"felt tight\". Do add a token when you need a colour. Don't write a hex. Do put a view switcher in ViewToolbar, first, as Segmented. Don't putit in PageHeader.actions or roll a pair of buttons. Do keep the toolbar where it is when the view changes. Don't move it intothe table card, and don't put a filter beside the page verb.","headings":[{"id":"design-system","text":"Design system","level":1,"line":0},{"id":"principles","text":"Principles","level":2,"line":16},{"id":"tokens","text":"Tokens","level":2,"line":34},{"id":"colour","text":"Colour","level":3,"line":41},{"id":"semantic-tones","text":"Semantic tones","level":3,"line":65},{"id":"typography","text":"Typography","level":3,"line":85},{"id":"spacing-radius-elevation","text":"Spacing, radius, elevation","level":3,"line":109},{"id":"motion","text":"Motion","level":3,"line":126},{"id":"themes","text":"Themes","level":2,"line":134},{"id":"interaction-states","text":"Interaction states","level":2,"line":144},{"id":"components","text":"Components","level":2,"line":158},{"id":"layout","text":"Layout","level":2,"line":218},{"id":"accessibility","text":"Accessibility","level":2,"line":272},{"id":"do--dont","text":"Do / don't","level":2,"line":286}],"kind":"markdown"},{"slug":"documentation","title":"Contribute documentation","description":"Add canonical content and validate every documentation interface.","source":"docs/development/documentation.md","audience":"developer","section":"Development","category":"","url":"/docs/documentation","markdown":"# Contribute documentation\n\nDocumentation is part of the installed product. Markdown is canonical; every interface reads the same structured corpus.\n\n## Add a page\n\n1. Choose `docs/product/` for user/operator content or `docs/development/` for contribution content.\n2. Decide whether the reader is learning, performing a task, consulting a contract, or understanding a concept.\n3. Write one H1 and a concise introduction. Add prerequisites and expected results to procedures when useful.\n4. Register the source, stable slug and description in `docs/navigation.json`. Array position determines order; the group determines audience and category. Markdown titles come only from H1.\n5. Run `npm run docs:generate`, then `npm run docs:check`.\n\nDo not add front matter, numbered filenames for order, or unpublished files to the public roots. Internal instructions and research are repository-only. A new public page without classification fails validation.\n\n## Writing and examples\n\nUse English. Use Project, Environment and Service for named Portta entities; commands and configuration keys keep their exact spelling. Concepts use nouns, procedures use verbs, and references name their subject.\n\nUse `demo-shop` for a Project, `development` for its purpose, `demo-shop-development` for the Compose namespace and `web` for its HTTP service. Read actual URLs from `portta urls`.\n\nKeep paragraphs short. A reference uses tables and contracts; a tutorial uses ordered steps and expected results. Link to the canonical explanation instead of repeating it. Commands use `bash` fences without `$`; output uses a separate `text` fence. Use descriptive link labels.\n\n## Markdown features\n\nRelative links work on GitHub and resolve to stable panel URLs. Use H2 and H3 for normal sections. Anchors follow GitHub heading rules; repeated headings gain numeric suffixes. Images live in `docs/images/` with meaningful alt text. Mermaid fences render locally and retain readable source on failure.\n\nUse GitHub alerts: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`. Reserve `CAUTION` for data loss, exposure or significant operational impact. State requirements or experimental status explicitly in the alert text.\n\n## Architecture\n\n`navigation.json` defines audience, groups, descriptions and stable public slugs. Core provides pure compilation and query functions. The build reads sources and validates links, then emits a versioned corpus. The server loads that corpus for the API; Next renders its Markdown. In development, the server compiles source changes in memory because the documentation mount is read-only. CLI and MCP query the bundled corpus locally or an explicitly selected panel.\n\nThe README index and citation map are generated outputs. Never edit them independently. Public content must work offline and match the installed version. Internal research, agent instructions and audits are never included in the published corpus.\n\n## Validation\n\nRun the documentation check for content changes. Add focused tests when changing compilation, search or interfaces. Test keyboard navigation, mobile navigation, focus, TOC and both themes when changing those interactions. Broad regression belongs to integration and release milestones.\n","text":"Contribute documentation Documentation is part of the installed product. Markdown is canonical; every interface reads the same structured corpus. Add a page Choose docs/product/ for user/operator content or docs/development/ for contribution content. Decide whether the reader is learning, performing a task, consulting a contract, or understanding a concept. Write one H1 and a concise introduction. Add prerequisites and expected results to procedures when useful. Register the source, stable slug and description in docs/navigation.json. Array position determines order; the group determines audience and category. Markdown titles come only from H1. Run npm run docs:generate, then npm run docs:check. Do not add front matter, numbered filenames for order, or unpublished files to the public roots. Internal instructions and research are repository-only. A new public page without classification fails validation. Writing and examples Use English. Use Project, Environment and Service for named Portta entities; commands and configuration keys keep their exact spelling. Concepts use nouns, procedures use verbs, and references name their subject. Use demo-shop for a Project, development for its purpose, demo-shop-development for the Compose namespace and web for its HTTP service. Read actual URLs from portta urls. Keep paragraphs short. A reference uses tables and contracts; a tutorial uses ordered steps and expected results. Link to the canonical explanation instead of repeating it. Commands use bash fences without $; output uses a separate text fence. Use descriptive link labels. Markdown features Relative links work on GitHub and resolve to stable panel URLs. Use H2 and H3 for normal sections. Anchors follow GitHub heading rules; repeated headings gain numeric suffixes. Images live in docs/images/ with meaningful alt text. Mermaid fences render locally and retain readable source on failure. Use GitHub alerts: NOTE, TIP, IMPORTANT, WARNING, CAUTION. Reserve CAUTION for data loss, exposure or significant operational impact. State requirements or experimental status explicitly in the alert text. Architecture navigation.json defines audience, groups, descriptions and stable public slugs. Core provides pure compilation and query functions. The build reads sources and validates links, then emits a versioned corpus. The server loads that corpus for the API; Next renders its Markdown. In development, the server compiles source changes in memory because the documentation mount is read-only. CLI and MCP query the bundled corpus locally or an explicitly selected panel. The README index and citation map are generated outputs. Never edit them independently. Public content must work offline and match the installed version. Internal research, agent instructions and audits are never included in the published corpus. Validation Run the documentation check for content changes. Add focused tests when changing compilation, search or interfaces. Test keyboard navigation, mobile navigation, focus, TOC and both themes when changing those interactions. Broad regression belongs to integration and release milestones.","headings":[{"id":"contribute-documentation","text":"Contribute documentation","level":1,"line":0},{"id":"add-a-page","text":"Add a page","level":2,"line":4},{"id":"writing-and-examples","text":"Writing and examples","level":2,"line":14},{"id":"markdown-features","text":"Markdown features","level":2,"line":22},{"id":"architecture","text":"Architecture","level":2,"line":28},{"id":"validation","text":"Validation","level":2,"line":34}],"kind":"markdown"},{"slug":"adr/0001-decoupled-infrastructure","title":"0001. The gateway is infrastructure, not a parent project","description":"0001. The gateway is infrastructure, not a parent project.","source":"docs/development/adr/0001-decoupled-infrastructure.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0001-decoupled-infrastructure","markdown":"# 0001. The gateway is infrastructure, not a parent project\n\n**Status:** Accepted\n\n## Context\n\nSeveral projects, and several worktrees of the same project, need to run at\nonce on one machine. Each wants web on 3000, an API on 8000, Postgres on 5432\nand Redis on 6379. Only one process can hold a given host port, so today the\nusual fix is to stop somebody else's containers or renumber ports inside a\nproject. Both are bad: the first breaks other people's work, the second makes a\ndevelopment environment differ from production for no reason.\n\nThe obvious-looking solution is a single Compose project that owns everything.\nThat would work and it would be a trap: projects would have to live in one\ndirectory tree, share a release cycle, and hand over ownership of their volumes\nand databases to a tool none of them chose.\n\n## Decision\n\nPortta is installed once per host and stays completely decoupled from\nthe projects that use it.\n\nIt **does not**: move or clone projects, mount their directories, own their\ncontainers, volumes, networks or databases, or take part in their lifecycle.\nIt never stops a consumer container, never removes a consumer volume, and never\nruns `docker system prune`.\n\nIntegration is a small contract, nothing more:\n\n1. the project attaches its published HTTP services to an external Docker\n network the gateway owns;\n2. those services set `traefik.enable=true`;\n3. the project sets a unique `COMPOSE_PROJECT_NAME`.\n\nThe recommended shape for that contract is a separate overlay file the project\nowns, `compose.portta.yaml`, so the project's own Compose file keeps\ndescribing the application and nothing else, and still runs standalone.\n\nEverything the gateway creates is labelled `portta.managed=true`, and\nevery destructive path checks that label first.\n\n## Consequences\n\nGood: any Docker project can adopt the gateway by adding one file; projects\nstay in their own repositories; the gateway can be removed without touching\nanyone's data; several gateways could even coexist on different networks.\n\nCosts: the gateway cannot fix a project that misconfigures itself. It can only\ndetect and report, which is why `doctor` and `analyze` carry so much weight. It\nalso cannot start or stop applications for you, by design.\n\n### Amendment: the panel may operate a project, without owning it\n\n**Amended 2026-09-02 by [ADR 0030](0030-the-panel-and-a-project-lifecycle.md).**\nThe Decision above is unchanged as an *ownership* claim: Portta still does not\nmove or clone projects, mount their directories into the gateway, own their\nvolumes or networks, or run `docker system prune`. What the letter no longer\nmatches is the shipped panel, which already starts, stops, restarts and removes\none consumer container at a time.\n\nADR 0030 redraws that line: Portta may operate a project's runtime state on\nrequest, and may ask Compose to rebuild or take a project down through one\nopt-in runner whose command is fixed at creation. It still does not own the\nproject. The Consequences sentence \"cannot start or stop applications for you,\nby design\" is the part this amendment replaces; everything else in this record\nstands.\n","text":"0001. The gateway is infrastructure, not a parent project Status: Accepted Context Several projects, and several worktrees of the same project, need to run atonce on one machine. Each wants web on 3000, an API on 8000, Postgres on 5432and Redis on 6379. Only one process can hold a given host port, so today theusual fix is to stop somebody else's containers or renumber ports inside aproject. Both are bad: the first breaks other people's work, the second makes adevelopment environment differ from production for no reason. The obvious-looking solution is a single Compose project that owns everything.That would work and it would be a trap: projects would have to live in onedirectory tree, share a release cycle, and hand over ownership of their volumesand databases to a tool none of them chose. Decision Portta is installed once per host and stays completely decoupled fromthe projects that use it. It does not: move or clone projects, mount their directories, own theircontainers, volumes, networks or databases, or take part in their lifecycle.It never stops a consumer container, never removes a consumer volume, and neverruns docker system prune. Integration is a small contract, nothing more: the project attaches its published HTTP services to an external Dockernetwork the gateway owns; those services set traefik.enable=true; the project sets a unique COMPOSE_PROJECT_NAME. The recommended shape for that contract is a separate overlay file the projectowns, compose.portta.yaml, so the project's own Compose file keepsdescribing the application and nothing else, and still runs standalone. Everything the gateway creates is labelled portta.managed=true, andevery destructive path checks that label first. Consequences Good: any Docker project can adopt the gateway by adding one file; projectsstay in their own repositories; the gateway can be removed without touchinganyone's data; several gateways could even coexist on different networks. Costs: the gateway cannot fix a project that misconfigures itself. It can onlydetect and report, which is why doctor and analyze carry so much weight. Italso cannot start or stop applications for you, by design. Amendment: the panel may operate a project, without owning it Amended 2026-09-02 by ADR 0030.The Decision above is unchanged as an ownership claim: Portta still does notmove or clone projects, mount their directories into the gateway, own theirvolumes or networks, or run docker system prune. What the letter no longermatches is the shipped panel, which already starts, stops, restarts and removesone consumer container at a time. ADR 0030 redraws that line: Portta may operate a project's runtime state onrequest, and may ask Compose to rebuild or take a project down through oneopt-in runner whose command is fixed at creation. It still does not own theproject. The Consequences sentence \"cannot start or stop applications for you,by design\" is the part this amendment replaces; everything else in this recordstands.","headings":[{"id":"0001-the-gateway-is-infrastructure-not-a-parent-project","text":"0001. The gateway is infrastructure, not a parent project","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":18},{"id":"consequences","text":"Consequences","level":2,"line":42},{"id":"amendment-the-panel-may-operate-a-project-without-owning-it","text":"Amendment: the panel may operate a project, without owning it","level":3,"line":52}],"kind":"markdown"},{"slug":"adr/0002-docker-socket-proxy","title":"0002. Traefik reaches Docker through a filtered read-only proxy","description":"0002. Traefik reaches Docker through a filtered read-only proxy.","source":"docs/development/adr/0002-docker-socket-proxy.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0002-docker-socket-proxy","markdown":"# 0002. Traefik reaches Docker through a filtered read-only proxy\n\n**Status:** Accepted\n\n## Context\n\nTraefik's Docker provider needs to read the container list and the event stream\nto discover routes. The common recipe bind-mounts `/var/run/docker.sock` into\nthe Traefik container. The Docker API is not namespaced: a process that can\nreach that socket can create a privileged container and is therefore root on\nthe host. Traefik is the one component in this design that is exposed to\nnetwork traffic, so it is the last place that access belongs.\n\n## Decision\n\nTraefik never sees the Docker socket. A `tecnativa/docker-socket-proxy`\ncontainer mounts it **read-only** and republishes a filtered subset of the API\nover TCP:\n\n- allowed: `CONTAINERS`, `NETWORKS`, `EVENTS`, `PING`, `VERSION`\n- denied: everything else, including all writes (`POST=0`)\n\nThe proxy sits alone with Traefik on `portta-control`, a network created\nwith `internal: true`, and publishes no host port. Traefik talks to it at\n`tcp://socket-proxy:2375`.\n\n`doctor` fails if the socket is mounted into Traefik, if the proxy mount is\nwritable, if the proxy publishes a host port, or if the control network is not\ninternal.\n\n## Consequences\n\nA compromised Traefik can no longer start containers or reach the host.\n\nTwo limits are worth stating plainly. First, `/containers/{id}/json` is\nrequired for discovery and includes container environment variables, so a\ncompromised Traefik could still read secrets that consumer projects pass as\nenv vars. That is inherent to Traefik's Docker provider, not to this proxy.\nSecond, the proxy is a small HAProxy configuration doing path filtering, not a\npolicy engine; it reduces blast radius rather than eliminating it.\n\n`wollomatic/socket-proxy` was considered as an alternative: it offers finer\nregex-level allowlisting and mTLS. The Tecnativa image was chosen for its much\nwider deployment and simpler configuration surface. Switching later means\nchanging one image and its environment block.\n","text":"0002. Traefik reaches Docker through a filtered read-only proxy Status: Accepted Context Traefik's Docker provider needs to read the container list and the event streamto discover routes. The common recipe bind-mounts /var/run/docker.sock intothe Traefik container. The Docker API is not namespaced: a process that canreach that socket can create a privileged container and is therefore root onthe host. Traefik is the one component in this design that is exposed tonetwork traffic, so it is the last place that access belongs. Decision Traefik never sees the Docker socket. A tecnativa/docker-socket-proxycontainer mounts it read-only and republishes a filtered subset of the APIover TCP: allowed: CONTAINERS, NETWORKS, EVENTS, PING, VERSION denied: everything else, including all writes (POST=0) The proxy sits alone with Traefik on portta-control, a network createdwith internal: true, and publishes no host port. Traefik talks to it attcp://socket-proxy:2375. doctor fails if the socket is mounted into Traefik, if the proxy mount iswritable, if the proxy publishes a host port, or if the control network is notinternal. Consequences A compromised Traefik can no longer start containers or reach the host. Two limits are worth stating plainly. First, /containers/{id}/json isrequired for discovery and includes container environment variables, so acompromised Traefik could still read secrets that consumer projects pass asenv vars. That is inherent to Traefik's Docker provider, not to this proxy.Second, the proxy is a small HAProxy configuration doing path filtering, not apolicy engine; it reduces blast radius rather than eliminating it. wollomatic/socket-proxy was considered as an alternative: it offers finerregex-level allowlisting and mTLS. The Tecnativa image was chosen for its muchwider deployment and simpler configuration surface. Switching later meanschanging one image and its environment block.","headings":[{"id":"0002-traefik-reaches-docker-through-a-filtered-read-only-proxy","text":"0002. Traefik reaches Docker through a filtered read-only proxy","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":13},{"id":"consequences","text":"Consequences","level":2,"line":30}],"kind":"markdown"},{"slug":"adr/0003-traefik-static-config-via-env","title":"0003. Traefik static configuration lives in environment variables","description":"0003. Traefik static configuration lives in environment variables.","source":"docs/development/adr/0003-traefik-static-config-via-env.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0003-traefik-static-config-via-env","markdown":"# 0003. Traefik static configuration lives in environment variables\n\n**Status:** Accepted\n\n## Context\n\nTraefik takes its static configuration from a file, from CLI flags, or from\nenvironment variables, and the three are **mutually exclusive**. Traefik's own\ndocumentation is explicit that mixing them is unsupported.\n\nThat forces a real choice, because our static configuration varies by profile:\nthe local profile serves plain HTTP on loopback, the private profile serves the\ntailnet, the public profile enables ACME. We compose profiles as Compose\noverlays, so the configuration mechanism has to compose too.\n\n- A **config file** reads best but cannot be templated by Compose; profiles\n would need a generation step and the generated file could drift from `.env`.\n- **CLI flags** interpolate fine, but Compose *replaces* `command:` wholesale\n when overlays are merged, so each profile would have to restate the entire\n flag list.\n- **Environment variables** interpolate from `.env` and Compose *merges*\n `environment:` maps across overlay files.\n\n## Decision\n\nStatic configuration is `TRAEFIK_*` environment variables in\n`docker/compose/compose.yaml`, with each profile overlay contributing only the\nkeys it changes.\n\nDynamic configuration (middlewares, TLS options) stays in real YAML under\n`config/traefik/dynamic/`, loaded by the file provider and hot-reloaded. That\nkeeps the split the Traefik documentation recommends: static in one place,\ndynamic in another.\n\n## Consequences\n\nProfiles stay small and additive, and every value traces back to a documented\nvariable in `.env.example`.\n\nThe cost is readability: a long `environment:` block is less pleasant than a\nYAML tree, and keys are shouty (`TRAEFIK_PROVIDERS_DOCKER_EXPOSEDBYDEFAULT`).\nNested keys also need care: `aliasHeadersStrategy` is\n`TRAEFIK_ENTRYPOINTS_WEB_HTTP_ALIASHEADERSSTRATEGY`, under `http`, not directly\nunder the entry point. When a key is wrong Traefik refuses to start and says\nexactly which node it could not decode, so mistakes surface immediately rather\nthan silently.\n","text":"0003. Traefik static configuration lives in environment variables Status: Accepted Context Traefik takes its static configuration from a file, from CLI flags, or fromenvironment variables, and the three are mutually exclusive. Traefik's owndocumentation is explicit that mixing them is unsupported. That forces a real choice, because our static configuration varies by profile:the local profile serves plain HTTP on loopback, the private profile serves thetailnet, the public profile enables ACME. We compose profiles as Composeoverlays, so the configuration mechanism has to compose too. A config file reads best but cannot be templated by Compose; profileswould need a generation step and the generated file could drift from .env. CLI flags interpolate fine, but Compose replaces command: wholesalewhen overlays are merged, so each profile would have to restate the entireflag list. Environment variables interpolate from .env and Compose mergesenvironment: maps across overlay files. Decision Static configuration is TRAEFIK_* environment variables indocker/compose/compose.yaml, with each profile overlay contributing only thekeys it changes. Dynamic configuration (middlewares, TLS options) stays in real YAML underconfig/traefik/dynamic/, loaded by the file provider and hot-reloaded. Thatkeeps the split the Traefik documentation recommends: static in one place,dynamic in another. Consequences Profiles stay small and additive, and every value traces back to a documentedvariable in .env.example. The cost is readability: a long environment: block is less pleasant than aYAML tree, and keys are shouty (TRAEFIK_PROVIDERS_DOCKER_EXPOSEDBYDEFAULT).Nested keys also need care: aliasHeadersStrategy isTRAEFIK_ENTRYPOINTS_WEB_HTTP_ALIASHEADERSSTRATEGY, under http, not directlyunder the entry point. When a key is wrong Traefik refuses to start and saysexactly which node it could not decode, so mistakes surface immediately ratherthan silently.","headings":[{"id":"0003-traefik-static-configuration-lives-in-environment-variables","text":"0003. Traefik static configuration lives in environment variables","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":23},{"id":"consequences","text":"Consequences","level":2,"line":34}],"kind":"markdown"},{"slug":"adr/0004-pinned-versions","title":"0004. Every component image pins an explicit version","description":"0004. Every component image pins an explicit version.","source":"docs/development/adr/0004-pinned-versions.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0004-pinned-versions","markdown":"# 0004. Every component image pins an explicit version\n\n**Status:** Accepted\n\n## Context\n\nThis is shared infrastructure: when it breaks, several projects stop being\nreachable at once. A floating `latest` tag turns an unrelated upstream release\ninto an outage at the least convenient moment, and makes two machines running\n\"the same\" gateway behave differently.\n\n## Decision\n\nEvery image pins an explicit version tag. As of this writing:\n\n| Component | Version | Why this one |\n|---|---|---|\n| `traefik` | `v3.7.12` | current stable v3 line |\n| `tecnativa/docker-socket-proxy` | `v0.5.0` | current release |\n| `tailscale/tailscale` | `v1.102.3` | current stable |\n| `alpine/socat` | `1.8.1.3` | TCP access bridges |\n| `traefik/whoami` | `v1.12.0` | fixtures only |\n| `postgres` | `18.6-alpine` | panel persistence and fixtures |\n| `redis` | `8.10.1-alpine` | fixtures only |\n| `nginx` | `1.31.4-alpine` | fixtures only |\n| `mysql` | `8.4.7` | fixtures only (`demo-shop`) |\n| `axllent/mailpit` | `v1.31.0` | fixtures only (mail capture UI) |\n| `rustfs/rustfs` | `1.0.0-rc.4` | fixtures only (S3-compatible storage) |\n| `alpine` | `3.24.1` | toolbox base, fixtures |\n| `node` | `24.20.0-alpine` | web panel build and runtime; demo workers |\n| `fabioassuncao/portta` | generated runtime version | local release image shared by the panel and ForwardAuth |\n| `fabioassuncao/portta-apply` | generated runtime version | the optional applier and runner ([ADR 0026](0026-applying-settings-from-the-panel.md)) |\n| `fabioassuncao/portta-toolbox` | generated runtime version | local diagnostic and database clients |\n\nThe static Compose checks fail the build if any image lacks an explicit tag,\nif a `:latest` appears anywhere, or if a pinned image is missing from the table\nabove. `doctor` warns if the running Traefik is on a floating tag.\n\nWe pin tags, not digests. A digest would be stronger against tag mutation, but\nit makes the update path opaque, since nobody can tell what `sha256:9f2c…` is\nby reading it, and there is no automation here to keep digests fresh. Pinning a\ntag we can read and audit is the better trade at this size.\n\n## Consequences\n\nUpgrades are deliberate: change the tag, run `portta update`, which\nvalidates the Compose configuration before pulling and asks before recreating.\n\nThe cost is that security updates do not arrive on their own. The\nversions above need periodic review, and the CHANGELOG records each bump.\n","text":"0004. Every component image pins an explicit version Status: Accepted Context This is shared infrastructure: when it breaks, several projects stop beingreachable at once. A floating latest tag turns an unrelated upstream releaseinto an outage at the least convenient moment, and makes two machines running\"the same\" gateway behave differently. Decision Every image pins an explicit version tag. As of this writing: Component Version Why this one traefik v3.7.12 current stable v3 line tecnativa/docker-socket-proxy v0.5.0 current release tailscale/tailscale v1.102.3 current stable alpine/socat 1.8.1.3 TCP access bridges traefik/whoami v1.12.0 fixtures only postgres 18.6-alpine panel persistence and fixtures redis 8.10.1-alpine fixtures only nginx 1.31.4-alpine fixtures only mysql 8.4.7 fixtures only (demo-shop) axllent/mailpit v1.31.0 fixtures only (mail capture UI) rustfs/rustfs 1.0.0-rc.4 fixtures only (S3-compatible storage) alpine 3.24.1 toolbox base, fixtures node 24.20.0-alpine web panel build and runtime; demo workers fabioassuncao/portta generated runtime version local release image shared by the panel and ForwardAuth fabioassuncao/portta-apply generated runtime version the optional applier and runner (ADR 0026) fabioassuncao/portta-toolbox generated runtime version local diagnostic and database clients The static Compose checks fail the build if any image lacks an explicit tag,if a :latest appears anywhere, or if a pinned image is missing from the tableabove. doctor warns if the running Traefik is on a floating tag. We pin tags, not digests. A digest would be stronger against tag mutation, butit makes the update path opaque, since nobody can tell what sha256:9f2c… isby reading it, and there is no automation here to keep digests fresh. Pinning atag we can read and audit is the better trade at this size. Consequences Upgrades are deliberate: change the tag, run portta update, whichvalidates the Compose configuration before pulling and asks before recreating. The cost is that security updates do not arrive on their own. Theversions above need periodic review, and the CHANGELOG records each bump.","headings":[{"id":"0004-every-component-image-pins-an-explicit-version","text":"0004. Every component image pins an explicit version","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":11},{"id":"consequences","text":"Consequences","level":2,"line":43}],"kind":"markdown"},{"slug":"adr/0005-hostname-convention","title":"0005. Hostnames are derived from the labels Compose already injects","description":"0005. Hostnames are derived from the labels Compose already injects.","source":"docs/development/adr/0005-hostname-convention.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0005-hostname-convention","markdown":"# 0005. Hostnames are derived from the labels Compose already injects\n\n**Status:** Accepted\n\n## Context\n\nEvery service reachable through the gateway needs a unique hostname. Making\neach project spell out a full `Host(...)` rule would mean every worktree has to\nedit that rule, which is exactly the manual step that makes parallel\nenvironments annoying enough that people stop creating them.\n\nDocker Compose already labels every container it creates with\n`com.docker.compose.project` and `com.docker.compose.service`. Together those\nare unique on a host and are precisely the two things a hostname should carry.\n\n## Decision\n\nThe convention is:\n\n```\n<compose-project>-<service>.<domain>\n```\n\nIt is implemented once, in Traefik's `providers.docker.defaultRule`, as a Go\ntemplate over those labels:\n\n```\nHost(`{{ normalize (index .Labels \"com.docker.compose.project\") }}-{{ normalize (index .Labels \"com.docker.compose.service\") }}.<domain>`)\n```\n\n`normalize` makes the result DNS-safe. Containers with no Compose labels fall\nback to the normalised container name. A project that wants a different\nhostname sets an explicit `traefik.http.routers.<name>.rule` label, which wins.\n\nOne level of subdomain is used rather than `service.project.domain`, so a\nsingle wildcard certificate (`*.dev.example.com`) covers everything.\n\n## Consequences\n\nA project opts in without naming itself anywhere, and a new worktree gets new\nhostnames purely by changing `COMPOSE_PROJECT_NAME`.\n\nThe trade-off is that hostnames get long (`base-empresarial-issue59-api`), and\nthat two project names differing only in punctuation normalise to the same\nlabel. `doctor` reports that collision rather than letting one project quietly\nreceive the other's traffic.\n","text":"0005. Hostnames are derived from the labels Compose already injects Status: Accepted Context Every service reachable through the gateway needs a unique hostname. Makingeach project spell out a full Host(...) rule would mean every worktree has toedit that rule, which is exactly the manual step that makes parallelenvironments annoying enough that people stop creating them. Docker Compose already labels every container it creates withcom.docker.compose.project and com.docker.compose.service. Together thoseare unique on a host and are precisely the two things a hostname should carry. Decision The convention is: It is implemented once, in Traefik's providers.docker.defaultRule, as a Gotemplate over those labels: normalize makes the result DNS-safe. Containers with no Compose labels fallback to the normalised container name. A project that wants a differenthostname sets an explicit traefik.http.routers.<name>.rule label, which wins. One level of subdomain is used rather than service.project.domain, so asingle wildcard certificate (*.dev.example.com) covers everything. Consequences A project opts in without naming itself anywhere, and a new worktree gets newhostnames purely by changing COMPOSE_PROJECT_NAME. The trade-off is that hostnames get long (base-empresarial-issue59-api), andthat two project names differing only in punctuation normalise to the samelabel. doctor reports that collision rather than letting one project quietlyreceive the other's traffic.","headings":[{"id":"0005-hostnames-are-derived-from-the-labels-compose-already-injects","text":"0005. Hostnames are derived from the labels Compose already injects","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":15},{"id":"consequences","text":"Consequences","level":2,"line":37}],"kind":"markdown"},{"slug":"adr/0006-compose-project-name-as-namespace","title":"0006. COMPOSE_PROJECT_NAME is the namespace for parallel environments","description":"0006. `COMPOSE_PROJECT_NAME` is the namespace for parallel environments.","source":"docs/development/adr/0006-compose-project-name-as-namespace.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0006-compose-project-name-as-namespace","markdown":"# 0006. `COMPOSE_PROJECT_NAME` is the namespace for parallel environments\n\n**Status:** Accepted\n\n## Context\n\nRunning the same project several times, a worktree per issue or one environment\nper agent, needs every piece of per-environment state to be distinct:\ncontainers, networks, volumes and hostnames. Inventing a gateway-specific\nregistry of environments would duplicate something Docker Compose already does.\n\n## Decision\n\n`COMPOSE_PROJECT_NAME` is the single namespace. Compose already derives\ncontainer names, the default network and volume names from it, and the gateway\nderives hostnames from it (ADR 0005). Setting it is enough:\n\n```bash\nCOMPOSE_PROJECT_NAME=base-empresarial-issue59 docker compose up -d\n```\n\nThe contract adds two rules that follow from this:\n\n- **No `container_name:`.** A fixed container name is global to the host and\n makes the second copy of a project fail to start.\n- **No shared external volumes across namespaces**, or two worktrees end up\n writing to one database.\n\nThe gateway keeps no registry of environments. `urls`, `status` and `doctor`\ndiscover everything from Docker labels at call time.\n\n## Consequences\n\nParallel environments cost one environment variable and no gateway-side\nbookkeeping, and an environment disappears completely when its Compose project\nis torn down.\n\nThe cost is that the namespace is the user's responsibility. Two projects that\npick the same name collide; `doctor` detects the resulting hostname and Traefik\nservice-name collisions and reports them.\n","text":"0006. COMPOSE_PROJECT_NAME is the namespace for parallel environments Status: Accepted Context Running the same project several times, a worktree per issue or one environmentper agent, needs every piece of per-environment state to be distinct:containers, networks, volumes and hostnames. Inventing a gateway-specificregistry of environments would duplicate something Docker Compose already does. Decision COMPOSE_PROJECT_NAME is the single namespace. Compose already derivescontainer names, the default network and volume names from it, and the gatewayderives hostnames from it (ADR 0005). Setting it is enough: The contract adds two rules that follow from this: No container_name:. A fixed container name is global to the host andmakes the second copy of a project fail to start. No shared external volumes across namespaces, or two worktrees end upwriting to one database. The gateway keeps no registry of environments. urls, status and doctordiscover everything from Docker labels at call time. Consequences Parallel environments cost one environment variable and no gateway-sidebookkeeping, and an environment disappears completely when its Compose projectis torn down. The cost is that the namespace is the user's responsibility. Two projects thatpick the same name collide; doctor detects the resulting hostname and Traefikservice-name collisions and reports them.","headings":[{"id":"0006-compose_project_name-is-the-namespace-for-parallel-environments","text":"0006. COMPOSE_PROJECT_NAME is the namespace for parallel environments","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":11},{"id":"consequences","text":"Consequences","level":2,"line":31}],"kind":"markdown"},{"slug":"adr/0007-tailscale-sidecar","title":"0007. Traefik runs inside the Tailscale container's network namespace","description":"0007. Traefik runs inside the Tailscale container's network namespace.","source":"docs/development/adr/0007-tailscale-sidecar.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0007-tailscale-sidecar","markdown":"# 0007. Traefik runs inside the Tailscale container's network namespace\n\n**Status:** Accepted\n\n## Context\n\nOn a VPS, the gateway should be reachable from the developer's machine and from\nnowhere else. Three ways to arrange that were considered.\n\n**A. Publish on the host and bind the tailnet address.** Simple, but it needs a\nhost-native Tailscale daemon so there *is* a tailnet address to bind, and the\naddress has to be known before Compose runs. It also leaves a published port\nwhose bind address is the only thing standing between private and public.\n\n**B. Tailscale as an L4 forwarder.** Keep Traefik ordinary and have Tailscale\nforward tailnet 80/443 into it, via `TS_SERVE_CONFIG` TCP forwarding or\n`TS_DEST_IP`. This composes beautifully, since Traefik never changes shape, but\n`TS_DEST_IP` needs a container IP that is not stable, and the serve config's\nTCP forwarder is documented mainly with loopback targets. Building the core\nprivate-access path on a capability we could not verify was not acceptable.\n\n**C. Share the network namespace.** `network_mode: service:tailscale` merges\nthe two containers' namespaces, so Traefik listens directly on the tailnet\ninterface. This is the pattern Tailscale's own Docker guidance and the wider\ncommunity converged on.\n\n## Decision\n\nOption C, as the default for `remote-private`. Option A remains supported and\ndocumented for hosts that need a host-native Tailscale anyway (subnet routing,\nTailscale SSH to the host, exit-node behaviour). Set `TAILSCALE_ENABLED=false`\nand point `PORTTA_BIND_ADDRESS` at the tailnet address.\n\nThree settings follow from the choice and are not optional:\n\n- `TS_USERSPACE=false`, with `/dev/net/tun` and `NET_ADMIN`. Userspace mode has\n no interface in the namespace, so an inbound connection would never land on\n Traefik.\n- `TS_ACCEPT_DNS=false`. MagicDNS rewrites `resolv.conf`, and Traefik shares\n this namespace: Docker's resolver has to keep working or discovery breaks.\n- `TS_AUTH_ONCE=true` with `state/tailscale/` persisted, so the node identity,\n and therefore the address DNS points at, survives a restart.\n\nBecause `networks:` and `network_mode:` are mutually exclusive, and Compose\nmerges rather than replaces both, Traefik's attachment cannot live in the base\nfile and be undone later. `docker/compose/compose.yaml` therefore declares\nneither, and exactly one overlay under `docker/compose/attach/` supplies it.\n\n## Consequences\n\nIn the private profile the VPS publishes **nothing** on its public interface.\nThere is no firewall rule to get wrong, which matters more than usual here\nbecause Docker's published ports bypass UFW.\n\nCosts: Traefik cannot have its own published ports in this mode, so the public\nprofile publishes on the Tailscale container instead, which is why\n`PORTTA_BIND_ADDRESS` is interpreted by whichever container owns the\nnamespace. The dashboard overlay needs a Tailscale-flavoured twin for the same\nreason. And `doctor` has to verify the namespace sharing explicitly, because a\nTraefik that quietly failed to join it looks healthy while being unreachable.\n\nThis path is **not covered by automated end-to-end tests**, because it needs a\nreal tailnet and a real VPS. Configuration tests assert that the profile renders and\nnever binds `0.0.0.0`; the rest is a documented manual checklist in\n`docs/remote-development.md`.\n","text":"0007. Traefik runs inside the Tailscale container's network namespace Status: Accepted Context On a VPS, the gateway should be reachable from the developer's machine and fromnowhere else. Three ways to arrange that were considered. A. Publish on the host and bind the tailnet address. Simple, but it needs ahost-native Tailscale daemon so there is a tailnet address to bind, and theaddress has to be known before Compose runs. It also leaves a published portwhose bind address is the only thing standing between private and public. B. Tailscale as an L4 forwarder. Keep Traefik ordinary and have Tailscaleforward tailnet 80/443 into it, via TS_SERVE_CONFIG TCP forwarding orTS_DEST_IP. This composes beautifully, since Traefik never changes shape, butTS_DEST_IP needs a container IP that is not stable, and the serve config'sTCP forwarder is documented mainly with loopback targets. Building the coreprivate-access path on a capability we could not verify was not acceptable. C. Share the network namespace. network_mode: service:tailscale mergesthe two containers' namespaces, so Traefik listens directly on the tailnetinterface. This is the pattern Tailscale's own Docker guidance and the widercommunity converged on. Decision Option C, as the default for remote-private. Option A remains supported anddocumented for hosts that need a host-native Tailscale anyway (subnet routing,Tailscale SSH to the host, exit-node behaviour). Set TAILSCALE_ENABLED=falseand point PORTTA_BIND_ADDRESS at the tailnet address. Three settings follow from the choice and are not optional: TS_USERSPACE=false, with /dev/net/tun and NET_ADMIN. Userspace mode hasno interface in the namespace, so an inbound connection would never land onTraefik. TS_ACCEPT_DNS=false. MagicDNS rewrites resolv.conf, and Traefik sharesthis namespace: Docker's resolver has to keep working or discovery breaks. TS_AUTH_ONCE=true with state/tailscale/ persisted, so the node identity,and therefore the address DNS points at, survives a restart. Because networks: and network_mode: are mutually exclusive, and Composemerges rather than replaces both, Traefik's attachment cannot live in the basefile and be undone later. docker/compose/compose.yaml therefore declaresneither, and exactly one overlay under docker/compose/attach/ supplies it. Consequences In the private profile the VPS publishes nothing on its public interface.There is no firewall rule to get wrong, which matters more than usual herebecause Docker's published ports bypass UFW. Costs: Traefik cannot have its own published ports in this mode, so the publicprofile publishes on the Tailscale container instead, which is whyPORTTA_BIND_ADDRESS is interpreted by whichever container owns thenamespace. The dashboard overlay needs a Tailscale-flavoured twin for the samereason. And doctor has to verify the namespace sharing explicitly, because aTraefik that quietly failed to join it looks healthy while being unreachable. This path is not covered by automated end-to-end tests, because it needs areal tailnet and a real VPS. Configuration tests assert that the profile renders andnever binds 0.0.0.0; the rest is a documented manual checklist indocs/remote-development.md.","headings":[{"id":"0007-traefik-runs-inside-the-tailscale-containers-network-namespace","text":"0007. Traefik runs inside the Tailscale container's network namespace","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":26},{"id":"consequences","text":"Consequences","level":2,"line":48}],"kind":"markdown"},{"slug":"adr/0008-web-panel-socket-proxy","title":"0008. The web panel gets its own Docker socket proxy","description":"0008. The web panel gets its own Docker socket proxy.","source":"docs/development/adr/0008-web-panel-socket-proxy.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0008-web-panel-socket-proxy","markdown":"# 0008. The web panel gets its own Docker socket proxy\n\n**Status:** Accepted\n\n## Context\n\nThe panel exists to make a busy Docker host legible: which projects the gateway\nroutes, what else is running beside them, which port is already taken, and\nwhere a database can be reached. Some of that is inherently write access:\nrestarting a service, stopping a container somebody forgot, removing one, and\nopening a TCP bridge.\n\nTraefik's socket proxy ([ADR 0002](0002-docker-socket-proxy.md)) is read-only,\ndeliberately: `POST: \"0\"` and every write flag off. Its permission set is a\npromise, and it is one of the few things in this repository that would be\ngenuinely dangerous to loosen. Extending it so that the panel can restart a\ncontainer would extend it for Traefik too, and Traefik is the component with\nthe largest attack surface in the stack.\n\n## Decision\n\nThe panel gets a **second** socket proxy of its own, on its own `internal`\nnetwork, reachable from nothing but the panel.\n\nIt grants the read endpoints the panel needs (containers, networks, events,\ninfo, version, ping) plus the container lifecycle (`POST`, with\n`ALLOW_START`, `ALLOW_STOP`, `ALLOW_RESTARTS`). Images, volumes, exec, build,\nswarm, secrets, plugins and the system endpoints stay denied.\n\nTraefik's proxy is untouched, and stays read-only.\n\nBecause `tecnativa/docker-socket-proxy` gates by path prefix and HTTP method,\n`CONTAINERS: \"1\"` together with `POST: \"1\"` is broader than what the panel\nneeds: it would also forward `POST /containers/prune` and\n`POST /containers/{id}/exec`. So the panel enforces a second, narrower layer in\nits own process: a hard allowlist of (method, path) pairs in\n`apps/web/src/server/docker/allowlist.ts`, checked before any request is emitted.\nA call not on that list never reaches the proxy.\n\nThe two layers together are what the panel is allowed to do:\n\n| Operation | Proxy | Panel allowlist |\n|---|---|---|\n| List, inspect, logs, events, info | allowed | allowed |\n| Start, stop, restart a container | allowed | allowed |\n| Remove a container | allowed | allowed, always with `v=0&link=0` |\n| Create a container | allowed | one shape only: the socat TCP bridge |\n| `exec`, `prune`, `archive`, `attach` | partly reachable | **denied** |\n| Images, volumes, build, swarm, secrets | denied | denied |\n\n`exec` deserves a note: the proxy would forward `POST /containers/{id}/exec`\n(it starts with `/containers`), but running one needs `POST /exec/{id}/start`,\nwhich `EXEC: \"0\"` denies. The panel's allowlist denies both regardless.\n\n## Consequences\n\nThere is one more container in the stack when the panel is enabled, and one\nmore place where socket permissions are declared. Both are worth it: the two\npermission sets have different justifications and different blast radii, and\nmerging them would mean the stricter one is only as strict as the looser one.\n\n`tests/unit/web.test.sh` asserts every flag in both directions, so a future\nedit that grants the panel `IMAGES` or `VOLUMES`, or that adds an allowlist\nrule for `/exec`, fails the build.\n\nThe panel needs no Docker socket, no Docker CLI and no host filesystem beyond\none file (`.env`, which its Settings page edits, and the image label/version it\nreads). It cannot pull an image, so `portta web up` pulls the bridge image\non the host, where the CLI already has real Docker access.\n\nThat is still true after\n[ADR 0026](0026-applying-settings-from-the-panel.md), and it is why that ADR is\nshaped the way it is: applying settings needs Compose on the host, so the host\nprepares a container to run it and the panel only *starts* it. Neither the\nproxy's flags nor the allowlist grew a single entry — `start` was already\nthere. A future change that grants the panel `IMAGES`, `VOLUMES`, or a rule for\n`/exec` is still the thing this ADR exists to prevent, and\n`tests/unit/web.test.sh` still fails the build for it.\n","text":"0008. The web panel gets its own Docker socket proxy Status: Accepted Context The panel exists to make a busy Docker host legible: which projects the gatewayroutes, what else is running beside them, which port is already taken, andwhere a database can be reached. Some of that is inherently write access:restarting a service, stopping a container somebody forgot, removing one, andopening a TCP bridge. Traefik's socket proxy (ADR 0002) is read-only,deliberately: POST: \"0\" and every write flag off. Its permission set is apromise, and it is one of the few things in this repository that would begenuinely dangerous to loosen. Extending it so that the panel can restart acontainer would extend it for Traefik too, and Traefik is the component withthe largest attack surface in the stack. Decision The panel gets a second socket proxy of its own, on its own internalnetwork, reachable from nothing but the panel. It grants the read endpoints the panel needs (containers, networks, events,info, version, ping) plus the container lifecycle (POST, withALLOW_START, ALLOW_STOP, ALLOW_RESTARTS). Images, volumes, exec, build,swarm, secrets, plugins and the system endpoints stay denied. Traefik's proxy is untouched, and stays read-only. Because tecnativa/docker-socket-proxy gates by path prefix and HTTP method,CONTAINERS: \"1\" together with POST: \"1\" is broader than what the panelneeds: it would also forward POST /containers/prune andPOST /containers/{id}/exec. So the panel enforces a second, narrower layer inits own process: a hard allowlist of (method, path) pairs inapps/web/src/server/docker/allowlist.ts, checked before any request is emitted.A call not on that list never reaches the proxy. The two layers together are what the panel is allowed to do: Operation Proxy Panel allowlist List, inspect, logs, events, info allowed allowed Start, stop, restart a container allowed allowed Remove a container allowed allowed, always with v=0&link=0 Create a container allowed one shape only: the socat TCP bridge exec, prune, archive, attach partly reachable denied Images, volumes, build, swarm, secrets denied denied exec deserves a note: the proxy would forward POST /containers/{id}/exec(it starts with /containers), but running one needs POST /exec/{id}/start,which EXEC: \"0\" denies. The panel's allowlist denies both regardless. Consequences There is one more container in the stack when the panel is enabled, and onemore place where socket permissions are declared. Both are worth it: the twopermission sets have different justifications and different blast radii, andmerging them would mean the stricter one is only as strict as the looser one. tests/unit/web.test.sh asserts every flag in both directions, so a futureedit that grants the panel IMAGES or VOLUMES, or that adds an allowlistrule for /exec, fails the build. The panel needs no Docker socket, no Docker CLI and no host filesystem beyondone file (.env, which its Settings page edits, and the image label/version itreads). It cannot pull an image, so portta web up pulls the bridge imageon the host, where the CLI already has real Docker access. That is still true afterADR 0026, and it is why that ADR isshaped the way it is: applying settings needs Compose on the host, so the hostprepares a container to run it and the panel only starts it. Neither theproxy's flags nor the allowlist grew a single entry — start was alreadythere. A future change that grants the panel IMAGES, VOLUMES, or a rule for/exec is still the thing this ADR exists to prevent, andtests/unit/web.test.sh still fails the build for it.","headings":[{"id":"0008-the-web-panel-gets-its-own-docker-socket-proxy","text":"0008. The web panel gets its own Docker socket proxy","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":19},{"id":"consequences","text":"Consequences","level":2,"line":54}],"kind":"markdown"},{"slug":"adr/0009-tcp-routing-by-hostname","title":"0009. Databases are told apart by hostname, with TLS terminated at the gateway","description":"0009. Databases are told apart by hostname, with TLS terminated at the gateway.","source":"docs/development/adr/0009-tcp-routing-by-hostname.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0009-tcp-routing-by-hostname","markdown":"# 0009. Databases are told apart by hostname, with TLS terminated at the gateway\n\n**Status:** Accepted\n\n## Context\n\nThe gateway solved port collisions for HTTP by routing on the Host header, so\nevery project keeps its own internal port and nothing publishes one. Databases\nnever got that: PostgreSQL, MySQL and Redis carry no hostname on the\nconnection, so the gateway reached them with a temporary loopback bridge on a\nport the kernel picks ([ADR 0002](0002-docker-socket-proxy.md) sits underneath,\n[docs/tcp-access.md](../../product/guides/tcp-access.md) explains the mechanism).\n\nThat works, and it is per-session. A database you connect to every day means\nopening a bridge every day, at a different port each time.\n\nTLS gives back the missing hostname: the Server Name Indication travels in the\nhandshake, in cleartext, before any application traffic. Traefik can route TCP\non it.\n\n## Decision\n\nThe gateway publishes **one entrypoint per protocol** and picks the backend\nfrom SNI. It is opt-in twice over: `PORTTA_TCP=true` on the gateway, and\nrouter labels on the project's own datastore.\n\nThree choices inside that, each with an alternative that was rejected:\n\n**TLS is terminated at the gateway, not passed through.** Passthrough would\nmean every project's Postgres and Redis needs a certificate, `ssl = on`, and a\nrenewal story. Terminating leaves consumer projects completely unchanged, which\nis the whole point of [ADR 0001](0001-decoupled-infrastructure.md). The cost is\nthat the gateway sees the traffic, on a host where it already terminates HTTPS\nand runs everything.\n\n**Hostnames are flat: `<project>-<service>.<domain>`.** The obvious\n`<service>.<project>.<domain>` cannot be covered by a wildcard certificate,\nwhich matches exactly one label. Verified: routing works, `verify-full` fails\nwith \"server certificate for `*.<domain>` does not match host name\". Flat reuses\n[ADR 0005](0005-hostname-convention.md) and the wildcard the gateway already\nissues.\n\n**Opted-in datastores join the access network, not the shared one.** The shared\n`portta` network carries HTTP and does not carry a database. Compose validation\nand routing tests enforce that boundary. The access network exists for reaching\nprivate TCP services, so Traefik joins it and nothing else changes.\n\n**MySQL is not supported, and no substitute is invented.** Its protocol has the\nserver send the first packet, so there is no SNI before a proxy must choose a\nbackend. ProxySQL, MySQL Router, routing by username and one IP per instance\nwere all considered and rejected: each adds a component or a change to every\nconnection string, to make one protocol look like the others. MySQL keeps the\nbridge, which still works.\n\n## Consequences\n\nTwo PostgreSQL instances can both use 5432 and be reached on one host port,\ntold apart by hostname, with neither publishing a port. The same for Redis.\nVerified with two live instances and distinct data in\n`tests/e2e/tcp-routing.test.sh`, which is what keeps it true.\n\nTLS becomes mandatory for these connections, because the hostname lives in the\nhandshake. `sslmode=require` is enough and needs no trust store; `verify-full`\nneeds the CA. A client that connects without TLS, or to an IP, gets an\n`HTTP/1.1 400 Bad Request` from Traefik's fallback muxer, which surfaces as\n\"received H\". The documentation says so, because the error does not.\n\nThe protocol registry lives in one place, `packages/core/src/discovery.ts`, and\na protocol is listed as routable only after two instances were reached through\none port. `unevaluated` is a real state, and the default.\n\nEverything here is additive. With `PORTTA_TCP=false`, which is the\ndefault, nothing changes: the bridges, the clients and the published ports all\nbehave exactly as before.\n","text":"0009. Databases are told apart by hostname, with TLS terminated at the gateway Status: Accepted Context The gateway solved port collisions for HTTP by routing on the Host header, soevery project keeps its own internal port and nothing publishes one. Databasesnever got that: PostgreSQL, MySQL and Redis carry no hostname on theconnection, so the gateway reached them with a temporary loopback bridge on aport the kernel picks (ADR 0002 sits underneath,docs/tcp-access.md explains the mechanism). That works, and it is per-session. A database you connect to every day meansopening a bridge every day, at a different port each time. TLS gives back the missing hostname: the Server Name Indication travels in thehandshake, in cleartext, before any application traffic. Traefik can route TCPon it. Decision The gateway publishes one entrypoint per protocol and picks the backendfrom SNI. It is opt-in twice over: PORTTA_TCP=true on the gateway, androuter labels on the project's own datastore. Three choices inside that, each with an alternative that was rejected: TLS is terminated at the gateway, not passed through. Passthrough wouldmean every project's Postgres and Redis needs a certificate, ssl = on, and arenewal story. Terminating leaves consumer projects completely unchanged, whichis the whole point of ADR 0001. The cost isthat the gateway sees the traffic, on a host where it already terminates HTTPSand runs everything. Hostnames are flat: <project>-<service>.<domain>. The obvious<service>.<project>.<domain> cannot be covered by a wildcard certificate,which matches exactly one label. Verified: routing works, verify-full failswith \"server certificate for *.<domain> does not match host name\". Flat reusesADR 0005 and the wildcard the gateway alreadyissues. Opted-in datastores join the access network, not the shared one. The sharedportta network carries HTTP and does not carry a database. Compose validationand routing tests enforce that boundary. The access network exists for reachingprivate TCP services, so Traefik joins it and nothing else changes. MySQL is not supported, and no substitute is invented. Its protocol has theserver send the first packet, so there is no SNI before a proxy must choose abackend. ProxySQL, MySQL Router, routing by username and one IP per instancewere all considered and rejected: each adds a component or a change to everyconnection string, to make one protocol look like the others. MySQL keeps thebridge, which still works. Consequences Two PostgreSQL instances can both use 5432 and be reached on one host port,told apart by hostname, with neither publishing a port. The same for Redis.Verified with two live instances and distinct data intests/e2e/tcp-routing.test.sh, which is what keeps it true. TLS becomes mandatory for these connections, because the hostname lives in thehandshake. sslmode=require is enough and needs no trust store; verify-fullneeds the CA. A client that connects without TLS, or to an IP, gets anHTTP/1.1 400 Bad Request from Traefik's fallback muxer, which surfaces as\"received H\". The documentation says so, because the error does not. The protocol registry lives in one place, packages/core/src/discovery.ts, anda protocol is listed as routable only after two instances were reached throughone port. unevaluated is a real state, and the default. Everything here is additive. With PORTTA_TCP=false, which is thedefault, nothing changes: the bridges, the clients and the published ports allbehave exactly as before.","headings":[{"id":"0009-databases-are-told-apart-by-hostname-with-tls-terminated-at-the-gateway","text":"0009. Databases are told apart by hostname, with TLS terminated at the gateway","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":20},{"id":"consequences","text":"Consequences","level":2,"line":54}],"kind":"markdown"},{"slug":"adr/0010-git-collected-on-the-host","title":"0010. Git is collected on the host, and the panel only reads the result","description":"0010. Git is collected on the host, and the panel only reads the result.","source":"docs/development/adr/0010-git-collected-on-the-host.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0010-git-collected-on-the-host","markdown":"# 0010. Git is collected on the host, and the panel only reads the result\n\n**Status:** Accepted, amended by [ADR 0018](0018-github-access-lives-in-the-panel.md)\n\nADR 0018 supersedes the sentences that forbade GitHub API access, webhooks\nand writes to issues from the panel. Local `git` collection, the\n`state/git/` snapshot, the refusal to mount project directories, and\nread-only working trees are unchanged. The sentence-by-sentence split is\nin ADR 0018.\n\n## Context\n\nOpening `base-empresarial-issue59` in the panel says it is a second worktree of\n`base-empresarial` and nothing else. Which branch it is on, whether there is\nuncommitted work, and which pull request it belongs to are all questions people\nanswer by leaving the panel for a terminal.\n\nThe panel cannot answer them today, and the reason is structural rather than\nmissing code. Its container gets exactly one host configuration path (`.env`),\nships no `git`, no `gh` and no Docker CLI, runs no shell commands, and reaches\nDocker through a proxy with `EXEC: \"0\"` and an allowlist that denies `exec`,\n`archive` and `prune` in the panel's own process\n([ADR 0008](0008-web-panel-socket-proxy.md)). The one container it may create\nhas a fixed shape with no binds at all.\n\nWhat it does already hold is the host path of every project: Compose writes\n`com.docker.compose.project.working_dir` on every container, and\n`apps/web/src/server/core/inventory.ts` already reads it. The panel knows exactly\nwhere each repository is. It just cannot look.\n\nEvery way of letting it look costs a guarantee:\n\n**Mounting the project directories** is the obvious one and the worst. It\ncontradicts [ADR 0001](0001-decoupled-infrastructure.md) in as many words, and\nit hands a container that may be routed over a VPN read access to every\nproject's source, `.env` files and credentials. On a VPS that is the machine.\n\n**Enabling `EXEC` and running `git` inside a project container** is arbitrary\ncommand execution in someone else's container, from a component reachable over\na network, and it usually does not even work: most project images carry no\n`git`, and the repository is frequently not mounted into them.\n\n**Generalising container creation** to spawn a short-lived `git` container with\na bind mount is the one that sounds reasonable. `createBridge` forbids binds by\nconstruction; loosening it turns \"one fixed shape\" into \"any host path into a\ncontainer\", which is the single thing ADR 0008 exists to prevent.\n\n**Calling the GitHub API from the panel with a token in `.env`** avoids the\nfilesystem entirely, and buys a long-lived credential in a file the panel\nitself can write, egress from a container that has none today, and our own\nrate-limit accounting.\n\n## Decision\n\nInvert it. The component that already runs on the host, already has `git`, and\nalready knows every project's directory is the CLI.\n\n`portta repos scan` reads the Compose labels, walks to each project's\nworking directory, runs read-only `git` there, and writes one file per project\nunder `state/git/`, mode `600`. `docker/compose/features/web.yaml` mounts that directory into\nthe panel read-only, and `GET /api/projects/:project/git` reads the file.\n\n```\nportta repos scan host: labels -> working_dir -> git -> gh\n |\n state/git/<project>.json one file per Compose project, mode 600\n |\n ./state/git:/app/state/git:ro\n |\n GET /api/projects/:project/git\n```\n\nFour things follow from that, and each is a decision of its own.\n\n**Local `git` is the primary source, and `gh` is optional on top.** Branch,\nHEAD, dirty counts, ahead/behind and the remote URL come from `git status\n--porcelain=v2 --branch` and one `rev-list`, which need no network and no\nauthentication and work for every forge and for no forge at all. Repository,\ncommit and branch web URLs are derived from the remote URL by string work, so\nGitHub, GitLab, Bitbucket and self-hosted remotes all get links. Open pull\nrequests come from `gh pr list --json` under an explicit `--with-prs`, reusing\nthe developer's existing authentication: no token in `.env`, nothing to leak\nfrom a panel that may be routed, no rate limit of ours to account for. No `gh`,\nno `forge` block, no GitHub section in the UI.\n\n**The data is a snapshot, and the panel says so.** Nothing polls. The scan runs\nfrom `portta up`, from `portta web up`, by hand, or from a cron the\nuser writes. Every file carries `collectedAt`; the panel renders the age,\nmarks anything past a threshold as stale, and prints the exact host command to\nrefresh it. That is the same honesty `doctor` and the pending-settings banner\nalready apply.\n\n**Metadata only.** Branch names, commit subjects, counts and URLs. Never a\ndiff, never a file's contents, never a credential, and never a commit list\nbeyond HEAD: a link to the repository beats a worse commit browser.\n\n**Read-only, in both directions.** No checkout, merge, rebase, reset, stash,\nfetch or push; no PR approval or merge; no webhook; no write to any repository.\nThe gateway observes environments, it does not drive them.\n\nAlongside it, three **optional** labels let a project declare what cannot be\nderived, extending `LABELS` in `apps/web/src/server/core/labels.ts`:\n\n| Label | What it settles |\n|---|---|\n| `portta.project` | The logical project, when `COMPOSE_PROJECT_NAME` is a per-worktree namespace, so several worktrees group under one heading |\n| `portta.repo` | `owner/name` or a remote URL, which gives forge links with no host-side Git at all |\n| `portta.git.root` | The repository root, when the Compose file is not at it (see [monorepos.md](../../product/guides/monorepos.md)) |\n\nEvery one of them is optional. The existing inference (`workingDir`, and a\n`namespace` derived when the directory basename disagrees with the project\nname) stays as the fallback, and a project that sets none behaves exactly as it\ndoes today. That is asserted in the test suite, not just promised here.\n\n## Consequences\n\nThe panel gains Git without gaining a single new capability: no project\ndirectory is mounted into it, `EXEC` stays off, container creation keeps its\none shape, and the new mount is read-only and contains nothing but metadata the\nscan chose to write.\n\nThe cost is freshness. What the panel shows is as true as the last scan, and it\nwill sometimes be wrong. The mitigation is to never imply otherwise: the age is\non screen, staleness is marked, and the refresh command is one copy away.\n\n`state/git/` is a new host path with a new failure mode: it is written by the\nCLI as the invoking user and read by a container that may run as `node`.\n`PORTTA_WEB_USER` already exists for the same reason on `.env`, and the\nscan makes the directory `700` and each file `600`.\n\nThere is a second reason the ordering matters. Branch names, commit subjects\nand PR titles are more sensitive than container names, and this makes the panel\nan inventory of what is being worked on as well as what is running. That is\nwhy [ADR 0012](0012-routed-panel-access-requires-authentication.md) comes first in the\nexecution plan and not last.\n\nA project that uses no Git degrades to no Git card. A repository with no\nremote loses the links and keeps the branch. A detached HEAD says so. A\nnon-GitHub remote keeps its derived links and has no pull requests. None of\nthose is an error, and the tests enumerate them.\n","text":"0010. Git is collected on the host, and the panel only reads the result Status: Accepted, amended by ADR 0018 ADR 0018 supersedes the sentences that forbade GitHub API access, webhooksand writes to issues from the panel. Local git collection, thestate/git/ snapshot, the refusal to mount project directories, andread-only working trees are unchanged. The sentence-by-sentence split isin ADR 0018. Context Opening base-empresarial-issue59 in the panel says it is a second worktree ofbase-empresarial and nothing else. Which branch it is on, whether there isuncommitted work, and which pull request it belongs to are all questions peopleanswer by leaving the panel for a terminal. The panel cannot answer them today, and the reason is structural rather thanmissing code. Its container gets exactly one host configuration path (.env),ships no git, no gh and no Docker CLI, runs no shell commands, and reachesDocker through a proxy with EXEC: \"0\" and an allowlist that denies exec,archive and prune in the panel's own process(ADR 0008). The one container it may createhas a fixed shape with no binds at all. What it does already hold is the host path of every project: Compose writescom.docker.compose.project.working_dir on every container, andapps/web/src/server/core/inventory.ts already reads it. The panel knows exactlywhere each repository is. It just cannot look. Every way of letting it look costs a guarantee: Mounting the project directories is the obvious one and the worst. Itcontradicts ADR 0001 in as many words, andit hands a container that may be routed over a VPN read access to everyproject's source, .env files and credentials. On a VPS that is the machine. Enabling EXEC and running git inside a project container is arbitrarycommand execution in someone else's container, from a component reachable overa network, and it usually does not even work: most project images carry nogit, and the repository is frequently not mounted into them. Generalising container creation to spawn a short-lived git container witha bind mount is the one that sounds reasonable. createBridge forbids binds byconstruction; loosening it turns \"one fixed shape\" into \"any host path into acontainer\", which is the single thing ADR 0008 exists to prevent. Calling the GitHub API from the panel with a token in .env avoids thefilesystem entirely, and buys a long-lived credential in a file the panelitself can write, egress from a container that has none today, and our ownrate-limit accounting. Decision Invert it. The component that already runs on the host, already has git, andalready knows every project's directory is the CLI. portta repos scan reads the Compose labels, walks to each project'sworking directory, runs read-only git there, and writes one file per projectunder state/git/, mode 600. docker/compose/features/web.yaml mounts that directory intothe panel read-only, and GET /api/projects/:project/git reads the file. Four things follow from that, and each is a decision of its own. Local git is the primary source, and gh is optional on top. Branch,HEAD, dirty counts, ahead/behind and the remote URL come from git status --porcelain=v2 --branch and one rev-list, which need no network and noauthentication and work for every forge and for no forge at all. Repository,commit and branch web URLs are derived from the remote URL by string work, soGitHub, GitLab, Bitbucket and self-hosted remotes all get links. Open pullrequests come from gh pr list --json under an explicit --with-prs, reusingthe developer's existing authentication: no token in .env, nothing to leakfrom a panel that may be routed, no rate limit of ours to account for. No gh,no forge block, no GitHub section in the UI. The data is a snapshot, and the panel says so. Nothing polls. The scan runsfrom portta up, from portta web up, by hand, or from a cron theuser writes. Every file carries collectedAt; the panel renders the age,marks anything past a threshold as stale, and prints the exact host command torefresh it. That is the same honesty doctor and the pending-settings banneralready apply. Metadata only. Branch names, commit subjects, counts and URLs. Never adiff, never a file's contents, never a credential, and never a commit listbeyond HEAD: a link to the repository beats a worse commit browser. Read-only, in both directions. No checkout, merge, rebase, reset, stash,fetch or push; no PR approval or merge; no webhook; no write to any repository.The gateway observes environments, it does not drive them. Alongside it, three optional labels let a project declare what cannot bederived, extending LABELS in apps/web/src/server/core/labels.ts: Label What it settles portta.project The logical project, when COMPOSE_PROJECT_NAME is a per-worktree namespace, so several worktrees group under one heading portta.repo owner/name or a remote URL, which gives forge links with no host-side Git at all portta.git.root The repository root, when the Compose file is not at it (see monorepos.md) Every one of them is optional. The existing inference (workingDir, and anamespace derived when the directory basename disagrees with the projectname) stays as the fallback, and a project that sets none behaves exactly as itdoes today. That is asserted in the test suite, not just promised here. Consequences The panel gains Git without gaining a single new capability: no projectdirectory is mounted into it, EXEC stays off, container creation keeps itsone shape, and the new mount is read-only and contains nothing but metadata thescan chose to write. The cost is freshness. What the panel shows is as true as the last scan, and itwill sometimes be wrong. The mitigation is to never imply otherwise: the age ison screen, staleness is marked, and the refresh command is one copy away. state/git/ is a new host path with a new failure mode: it is written by theCLI as the invoking user and read by a container that may run as node.PORTTA_WEB_USER already exists for the same reason on .env, and thescan makes the directory 700 and each file 600. There is a second reason the ordering matters. Branch names, commit subjectsand PR titles are more sensitive than container names, and this makes the panelan inventory of what is being worked on as well as what is running. That iswhy ADR 0012 comes first in theexecution plan and not last. A project that uses no Git degrades to no Git card. A repository with noremote loses the links and keeps the branch. A detached HEAD says so. Anon-GitHub remote keeps its derived links and has no pull requests. None ofthose is an error, and the tests enumerate them.","headings":[{"id":"0010-git-is-collected-on-the-host-and-the-panel-only-reads-the-result","text":"0010. Git is collected on the host, and the panel only reads the result","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":10},{"id":"decision","text":"Decision","level":2,"line":52},{"id":"consequences","text":"Consequences","level":2,"line":114}],"kind":"markdown"},{"slug":"adr/0011-bounded-traefik-write-surface","title":"0011. The panel has a bounded Traefik write surface","description":"0011. The panel has a bounded Traefik write surface.","source":"docs/development/adr/0011-bounded-traefik-write-surface.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0011-bounded-traefik-write-surface","markdown":"# 0011. The panel has a bounded Traefik write surface\n\n**Status:** Accepted\n\nThe panel reads Traefik's API for runtime routing facts. Its dynamic directory\nis writable only so it can materialize three current decisions:\n\n- `portta-aliases.yaml` for environment aliases;\n- `portta-shares.yaml` for expiring service shares;\n- `portta-auth.yaml` for protected project and share routes.\n\nEvery write passes through the filename allowlist in\n`packages/server/src/services/dynamic.ts`, uses an atomic replacement and mode\n0600, and refuses every other path. Files outside that allowlist belong to the\noperator and are never modified by the panel.\n","text":"0011. The panel has a bounded Traefik write surface Status: Accepted The panel reads Traefik's API for runtime routing facts. Its dynamic directoryis writable only so it can materialize three current decisions: portta-aliases.yaml for environment aliases; portta-shares.yaml for expiring service shares; portta-auth.yaml for protected project and share routes. Every write passes through the filename allowlist inpackages/server/src/services/dynamic.ts, uses an atomic replacement and mode0600, and refuses every other path. Files outside that allowlist belong to theoperator and are never modified by the panel.","headings":[{"id":"0011-the-panel-has-a-bounded-traefik-write-surface","text":"0011. The panel has a bounded Traefik write surface","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0012-routed-panel-access-requires-authentication","title":"0012. Routed panel access requires authentication","description":"0012. Routed panel access requires authentication.","source":"docs/development/adr/0012-routed-panel-access-requires-authentication.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0012-routed-panel-access-requires-authentication","markdown":"# 0012. Routed panel access requires authentication\n\n**Status:** Accepted, refined by [0035](0035-authentication-lives-in-the-panel.md)\n\nThe panel is loopback-only by default. Any access mode reachable from another\nmachine requires `PORTTA_AUTH_MODE=required`, TLS where the route crosses an\nuntrusted network, and the panel's own authorization checks. The Traefik\ndashboard remains on loopback and is never routed.\n","text":"0012. Routed panel access requires authentication Status: Accepted, refined by 0035 The panel is loopback-only by default. Any access mode reachable from anothermachine requires PORTTA_AUTH_MODE=required, TLS where the route crosses anuntrusted network, and the panel's own authorization checks. The Traefikdashboard remains on loopback and is never routed.","headings":[{"id":"0012-routed-panel-access-requires-authentication","text":"0012. Routed panel access requires authentication","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0013-what-the-panel-persists","title":"0013. The panel persists decisions, not runtime observations","description":"0013. The panel persists decisions, never observations.","source":"docs/development/adr/0013-what-the-panel-persists.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0013-what-the-panel-persists","markdown":"# 0013. The panel persists decisions, not runtime observations\n\n**Status:** Accepted\n\nPostgreSQL stores Portta's durable model: instance identity, Projects,\nrepositories, tasks, sessions, users, authorization grants, settings,\nintegrations and bounded activity. It is a required dependency of the panel.\n\nDocker owns container, network, mount, port, health and lifecycle facts.\nTraefik owns active routers and services. Host collection owns Git and machine\nmetrics. The panel reads those sources and does not copy their live state into\nthe database as another source of truth.\n\nThe CLI never opens PostgreSQL. Durable mutations go through the API, which\napplies the same schema, authorization and audit rules used by the panel and\nMCP server.\n","text":"0013. The panel persists decisions, not runtime observations Status: Accepted PostgreSQL stores Portta's durable model: instance identity, Projects,repositories, tasks, sessions, users, authorization grants, settings,integrations and bounded activity. It is a required dependency of the panel. Docker owns container, network, mount, port, health and lifecycle facts.Traefik owns active routers and services. Host collection owns Git and machinemetrics. The panel reads those sources and does not copy their live state intothe database as another source of truth. The CLI never opens PostgreSQL. Durable mutations go through the API, whichapplies the same schema, authorization and audit rules used by the panel andMCP server.","headings":[{"id":"0013-the-panel-persists-decisions-not-runtime-observations","text":"0013. The panel persists decisions, not runtime observations","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0014-monorepo-and-the-typescript-cli","title":"0014. The repository is an npm workspace with one TypeScript CLI","description":"0014. The repository is a small npm workspace, with a shared core.","source":"docs/development/adr/0014-monorepo-and-the-typescript-cli.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0014-monorepo-and-the-typescript-cli","markdown":"# 0014. The repository is an npm workspace with one TypeScript CLI\n\n**Status:** Accepted\n\n## Decision\n\nPortta is an npm workspace with application code under `apps/`, shared packages\nunder `packages/`, and operational assets under `docker/`, `config/` and\n`templates/`.\n\nThe public command is the `portta` package in `packages/cli`. Its bundled\nJavaScript is the sole CLI implementation. `bin/portta` is only a checkout\nlauncher for that bundle, and `install.sh` only invokes the published package.\n\nWorkspace responsibilities are:\n\n- `packages/core`: pure rules shared by more than one consumer;\n- `packages/contracts`: API schemas, types and generated OpenAPI;\n- `packages/db`: schema, current baseline migration and database client;\n- `packages/auth`: identity and authorization;\n- `packages/server`: business rules and integrations;\n- `apps/web`: the panel UI and process composition;\n- `apps/auth`: ForwardAuth for protected application hosts and shares;\n- `packages/cli`: commands, host effects, formatting and packaging.\n\nLocal facts are derived by Core and executed locally. Persistent decisions go\nthrough the panel API. The CLI never opens PostgreSQL and the UI does not\nreimplement server rules.\n\nNode 22.12 or newer is required for the CLI and installer. The npm package\ncarries the runtime Compose, image, template and configuration assets required\nby `portta setup`; installing a release does not require a Git checkout.\n\n## Consequences\n\n- A command is implemented once in TypeScript.\n- Shared rules have one implementation in Core.\n- Package boundaries are enforced by `tests/unit/boundaries.test.sh`.\n- Packaging must prove that the CLI starts and that the runtime assets are in\n the npm tarball.\n","text":"0014. The repository is an npm workspace with one TypeScript CLI Status: Accepted Decision Portta is an npm workspace with application code under apps/, shared packagesunder packages/, and operational assets under docker/, config/ andtemplates/. The public command is the portta package in packages/cli. Its bundledJavaScript is the sole CLI implementation. bin/portta is only a checkoutlauncher for that bundle, and install.sh only invokes the published package. Workspace responsibilities are: packages/core: pure rules shared by more than one consumer; packages/contracts: API schemas, types and generated OpenAPI; packages/db: schema, current baseline migration and database client; packages/auth: identity and authorization; packages/server: business rules and integrations; apps/web: the panel UI and process composition; apps/auth: ForwardAuth for protected application hosts and shares; packages/cli: commands, host effects, formatting and packaging. Local facts are derived by Core and executed locally. Persistent decisions gothrough the panel API. The CLI never opens PostgreSQL and the UI does notreimplement server rules. Node 22.12 or newer is required for the CLI and installer. The npm packagecarries the runtime Compose, image, template and configuration assets requiredby portta setup; installing a release does not require a Git checkout. Consequences A command is implemented once in TypeScript. Shared rules have one implementation in Core. Package boundaries are enforced by tests/unit/boundaries.test.sh. Packaging must prove that the CLI starts and that the runtime assets are inthe npm tarball.","headings":[{"id":"0014-the-repository-is-an-npm-workspace-with-one-typescript-cli","text":"0014. The repository is an npm workspace with one TypeScript CLI","level":1,"line":0},{"id":"decision","text":"Decision","level":2,"line":4},{"id":"consequences","text":"Consequences","level":2,"line":33}],"kind":"markdown"},{"slug":"adr/0015-node-is-required-on-the-host","title":"0015. Node is required on the host","description":"0015. Node is required on the host.","source":"docs/development/adr/0015-node-is-required-on-the-host.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0015-node-is-required-on-the-host","markdown":"# 0015. Node is required on the host\n\n**Status:** Accepted\n\n## Decision\n\nPortta requires Node 22.12 or newer and npm on the host. The requirement gives\nthe CLI, setup flow and checkout launcher one executable implementation.\n\n`install.sh` checks the requirement and invokes `npx portta setup`. It does not\ninstall operating-system packages or provide another command implementation.\nDocker Engine 24+ with Compose v2 remains required for gateway operations.\n\n## Consequences\n\n- Every documented command follows the TypeScript CLI path.\n- A release contains a self-contained CLI bundle and its runtime assets.\n- Unsupported Node versions fail before installation changes the host.\n","text":"0015. Node is required on the host Status: Accepted Decision Portta requires Node 22.12 or newer and npm on the host. The requirement givesthe CLI, setup flow and checkout launcher one executable implementation. install.sh checks the requirement and invokes npx portta setup. It does notinstall operating-system packages or provide another command implementation.Docker Engine 24+ with Compose v2 remains required for gateway operations. Consequences Every documented command follows the TypeScript CLI path. A release contains a self-contained CLI bundle and its runtime assets. Unsupported Node versions fail before installation changes the host.","headings":[{"id":"0015-node-is-required-on-the-host","text":"0015. Node is required on the host","level":1,"line":0},{"id":"decision","text":"Decision","level":2,"line":4},{"id":"consequences","text":"Consequences","level":2,"line":13}],"kind":"markdown"},{"slug":"adr/0016-state-that-could-be-shared","title":"0016. State that could be shared, and what must never be","description":"0016. State that could be shared, and what must never be.","source":"docs/development/adr/0016-state-that-could-be-shared.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0016-state-that-could-be-shared","markdown":"# 0016. State that could be shared, and what must never be\n\n**Status:** Accepted\n\nThis record classifies state and decides identity. **No synchronisation is\nbeing implemented.** Two gateways still know nothing about each other. The\ncost of keeping that possible later was paid by issue #4: three nullable\ncolumns, one instance row, and `updated_at` on every decision table. This\npage says what those seams are for, and what they are not for.\n\n## Context\n\nTwo Porttas, one person: a laptop and a development VPS, with the same\nrepositories checked out on both. Name a project *Storefront* on the laptop,\ngive it a short hostname, and none of that exists on the VPS — where an agent\nmay be doing the actual work.\n\nThe appealing version of this is \"sync the gateways\". The correct version is\nnarrower: **most of what a gateway knows is true only of the machine it runs\non**, and trying to share it would be actively wrong. A container id, a\nloopback port, an absolute path and a Docker network are facts about one host.\n\nIssue #4 already shipped PostgreSQL. The schema includes `instance.id` (a\nUUID), `projects.repo_url`, `projects.repo_subpath`, `projects.slug`, and\n`updated_at` on every decision table. This investigation validates those\nseams rather than requesting a future migration. [ADR 0014](0014-monorepo-and-the-typescript-cli.md)\nplaces persistent decisions behind the panel API; this record says which of\nthose decisions could ever travel.\n\nThis is not a multi-host dashboard. Sharing an administrative decision is not\nthe same feature as showing another machine's containers.\n\n## Decision\n\n> **Share what a person decided about a project. Never share what a machine\n> observed.**\n\n### Five kinds of state\n\nEvery fact the gateway holds on 2026-09-01, including what issues #1, #4 and\n#6 added.\n\n| Kind | Examples | Source today | Shareable |\n|---|---|---|---|\n| **Runtime** | container ids, state, health, uptime, published ports, networks, mounts, working_dir, access-bridge ports, Traefik routers and their live status, Docker logs and stats | Docker Engine, Traefik API, kernel-allocated ports | **Never.** Re-derived in milliseconds and wrong anywhere else |\n| **Instance** | bind address, domains, TLS mode, profile, ACME, `TS_AUTHKEY`, `CF_DNS_API_TOKEN`, panel auth hash, `COMPOSE_PROJECT_NAME` of the gateway itself, database password | `.env` | **Never.** Host-specific, and half of it is secret |\n| **Project** | display name, description, primary service, hidden services, ordering, notes, `repo_url` / `repo_subpath` / `slug` | PostgreSQL (`projects`, `project_settings`, `service_settings`) | **Yes**, with the identity rules below |\n| **User** | theme, default page, table density | PostgreSQL (`settings`) | **Yes**, and low stakes either way |\n| **Shareable, with translation** | hostname alias | PostgreSQL `service_settings.alias` plus a generated Traefik file | **Partly** — see aliases |\n\nGit snapshots under `state/git/` and host metrics under `state/metrics/` are\nruntime observations collected on the host\n([ADR 0010](0010-git-collected-on-the-host.md)). They are not shareable as a\nsource of truth; `repo_url` extracted from a Git file is a portable\n*coordinate*, which is a different column doing a different job.\n\nShare records (temporary extra hostnames with expiry) are instance-scoped:\nthey bind a live container name to a host-specific domain. They are not\nproject decisions.\n\nA future GitHub issue projection ([ADR 0018](0018-github-access-lives-in-the-panel.md))\nis a cache of a remote source of truth, not a decision and not a Docker\nobservation. It is not shared between instances by copying rows; each\ninstance talks to GitHub itself. Two panels holding the same App\ninstallation is a concurrency problem for issue #20, not a sync feature.\n\n`COMPOSE_PROJECT_NAME` of a *consumer* environment is local identity, not\nshareable on its own: `storefront` on the laptop and `storefront` on the VPS\nare probably the same project, and `storefront-issue59` is a worktree that\nmay not exist remotely at all.\n\n### Project identity\n\nNot a distributed identity system. A **local id plus portable coordinates**:\n\n```text\nprojects\n id BIGSERIAL local, never shared, never meaningful elsewhere\n compose_project TEXT UNIQUE the namespace, per ADR 0006 — local identity\n working_dir TEXT NULL local only\n repo_url TEXT NULL normalised remote: github.com/owner/repo\n repo_subpath TEXT NULL for a monorepo package\n slug TEXT NULL a stable, user-visible name, unique per instance\n```\n\nIssue #4 delivered this shape. `(repo_url, repo_subpath)` is the portable\ncoordinate. `slug` is the fallback for a project with no Git, and the manual\nassociation two people (or two gateways) can agree on.\n\nThe worktree case: `storefront` and `storefront-issue59` share a `repo_url`\nand differ in `compose_project`. Sharing, if it is ever built, operates on\nthe repository coordinate. Per-environment overrides stay local, which is\nwhat issue #5 already decided for the right reasons.\n[ADR 0013](0013-what-the-panel-persists.md) already forbids two worktrees\nfrom inheriting each other's aliases.\n\nNothing merges. `repo_url` is a coordinate, not a key.\n`compose_project` stays the local identity.\n\n### `repo_url` normalisation\n\nA pure function. Inputs that denote the same repository must compare equal.\nCovered forms, with examples:\n\n| Input | Normalised |\n|---|---|\n| `git@github.com:acme/storefront.git` | `github.com/acme/storefront` |\n| `https://github.com/acme/storefront` | `github.com/acme/storefront` |\n| `https://github.com/acme/storefront.git` | `github.com/acme/storefront` |\n| `ssh://git@github.com/acme/storefront.git` | `github.com/acme/storefront` |\n| `git@gitlab.com:acme/storefront.git` | `gitlab.com/acme/storefront` |\n| `https://git.example.com/acme/storefront.git` | `git.example.com/acme/storefront` |\n| empty / no remote | `null` |\n\nRules:\n\n1. Strip the scheme (`git+ssh://`, `ssh://`, `https://`, `http://`).\n2. Strip a leading `git@` user and rewrite `host:path` to `host/path`.\n3. Strip a trailing `.git`.\n4. Strip a trailing slash.\n5. Lowercase the host. Do **not** lowercase the path: some forges are\n case-sensitive, GitHub is not, and over-normalising merges distinct\n repositories on a self-hosted forge.\n6. Drop a trailing `.wiki.git` or `/issues` suffix; those are not the\n repository.\n7. Submodules are separate repositories with their own remotes; a consumer\n project's `repo_url` is the superproject, and `repo_subpath` names a\n package inside it, not a submodule.\n8. No remote, or a remote that is a local path, yields `null`. `slug` then\n carries identity if a person supplies one.\n\nThe function belongs in `packages/core` once the CLI and the panel both\nneed it. Until then it may live next to the Git collector. It is tested\nwith a fixture table covering every row above.\n\n### Instance identity\n\nIssue #4 created a singleton `instance` row: a generated UUID that never\nchanges, a human-chosen `name` (default `portta`), `created_at` and\n`updated_at`.\n\nThat is enough. `portta status --json` (issue #9) should include\n`instance.id` and `instance.name` so two gateways can be told apart the day\nthere are two. The UUID is local. It is not a tracking identifier, and\nnothing transmits it anywhere today.\n\n`updated_by_instance` on eligible rows is **not** added now. It is a sync\ncolumn, and this record forbids shipping sync machinery.\n\n### Aliases are labels, not hostnames\n\n`shop.localhost` on a laptop; `*.dev.example.com` on a VPS. A shared alias\ncannot be a hostname.\n\nThe stored value is a **DNS label**: `shop`. Each instance renders it against\nits own `PORTTA_DOMAIN`, `PRIVATE_DOMAIN` or `PUBLIC_DOMAIN`. The\nlaptop serves `shop.localhost`, the VPS serves `shop.dev.example.com`, and\nnothing shared contains a hostname.\n\nIssue #5's catalogue already matches this: `SERVICE_KEYS.alias` is a\nlowercase DNS label (letters, digits, hyphens, at most 63 characters), not a\nFQDN. Keep it that way. Rendering the hostname is instance-local and happens\nwhen the Traefik file is written, not when the preference is stored.\n\n### The four architectures, if synchronisation is ever built\n\n| | Central service | Central PostgreSQL | Peer-to-peer | Hybrid |\n|---|---|---|---|---|\n| **Shape** | Both instances call one control plane | Both connect to one database | Each keeps its own, exchanges changes | Local database, selected records via a shared layer |\n| **Complexity** | High — a service to build, host, secure, upgrade | Low to build | Very high — conflict resolution, vector clocks or CRDTs | Medium |\n| **Offline** | Degraded or broken | **Broken** — the laptop stops working on a plane | Full | Full |\n| **Security** | An authenticated API, tokens, rotation | A database open to the internet, or a tunnel | Mutual auth between instances | One narrow authenticated surface |\n| **Conflicts** | Server decides | Not applicable | The hard part | Last-write-wins on a small record set |\n| **Operational cost** | A service to run forever | A database to run forever | None beyond the instances | Small |\n| **Verdict** | Overkill for one person | **Disqualified by offline** | Disproportionate | **Recommended, if ever** |\n\nTwo of the four are eliminated on requirements rather than taste:\n\n- **Central PostgreSQL fails the basic requirement.** The gateway's whole\n purpose is a local development environment; one that stops working without\n a network connection is a worse tool than one that never synced.\n- **Peer-to-peer is a distributed-systems project.** Conflict resolution over\n an arbitrary record set is not a side feature of a development gateway.\n\n**If synchronisation is ever built, it should be hybrid and boring:**\n\n- each instance keeps its own PostgreSQL and works fully offline;\n- only `project` and `user` state is eligible, and only for projects with a\n `repo_url` or an explicit `slug`;\n- every eligible row already carries `updated_at`; a future sync change may\n add `updated_by_instance`;\n- reconciliation is **last-write-wins per field**, with both values shown in\n the panel when they differ — a person resolving a name clash is cheaper\n than a merge algorithm;\n- the transport is a push/pull command, not a daemon:\n `portta sync push` / `pull`, over the panel's existing authenticated\n API ([ADR 0012](0012-routed-panel-access-requires-authentication.md)) reached over the\n tailnet;\n- an append-only event log is **not** needed for last-write-wins on a few\n dozen rows.\n\nLast-write-wins loses data by design. Acceptable for a friendly name. It\nmust stay written down rather than discovered in a support thread.\n\n### The cheapest first experiment\n\nSync by committing state to a Git repository — a `~/.portta` repo\npushed and pulled — is not the plan. It is the cheapest prototype if this\nis ever tried: conflict resolution, history and transport all come free, and\nthe audience already uses Git constantly. It turns every preference change\ninto a commit and needs a repository provisioned per user, which is why it\nis recorded rather than chosen.\n\n### Asks already delivered, and asks that remain\n\n| Issue | Ask | Status on 2026-09-01 |\n|---|---|---|\n| #3 | Classify state five ways, not two; record the identity model | This ADR |\n| #4 | `repo_url`, `repo_subpath`, `slug` nullable on `projects`; an `instance` table with a UUID; `updated_at` on project- and user-scoped rows | **Delivered.** Defined in `packages/db/src/schema` |\n| #5 | Store the alias as a label, render the hostname per instance | Catalogue already stores a label (`SERVICE_KEYS.alias`). Rendering remains instance-local when the Traefik file is written |\n| #9 | `--json` output identifies the instance; the CLI never opens its own database connection | Still open. ADR 0014 already forbids the database connection |\n\n## Consequences\n\n- Two gateways still know nothing about each other.\n- Anyone proposing synchronisation later starts from a page that already\n says what may be shared, what identifies a project across machines, why a\n central database was ruled out, and why last-write-wins is enough.\n- If it is never built, the cost was three nullable columns and one table\n that made `portta status --json` able to say which gateway answered.\n- Runtime state must not leak into \"shareable\". A review check on every new\n column: is this a decision a person made, or an observation a machine\n made?\n- Issue #20 (a reconciliation loop plus a webhook receiver) makes this\n sharper: two panels holding the same GitHub App installation would both\n reconcile the same repositories. That issue does not solve sharing; it\n requires `github_sync_state` writes to be safe under concurrency.\n\n## What this record forbids\n\nNo synchronisation code, no sync table, no `updated_by_instance` column, and\nno network call ships from the change that accepts this ADR.\n","text":"0016. State that could be shared, and what must never be Status: Accepted This record classifies state and decides identity. No synchronisation isbeing implemented. Two gateways still know nothing about each other. Thecost of keeping that possible later was paid by issue #4: three nullablecolumns, one instance row, and updated_at on every decision table. Thispage says what those seams are for, and what they are not for. Context Two Porttas, one person: a laptop and a development VPS, with the samerepositories checked out on both. Name a project Storefront on the laptop,give it a short hostname, and none of that exists on the VPS — where an agentmay be doing the actual work. The appealing version of this is \"sync the gateways\". The correct version isnarrower: most of what a gateway knows is true only of the machine it runson, and trying to share it would be actively wrong. A container id, aloopback port, an absolute path and a Docker network are facts about one host. Issue #4 already shipped PostgreSQL. The schema includes instance.id (aUUID), projects.repo_url, projects.repo_subpath, projects.slug, andupdated_at on every decision table. This investigation validates thoseseams rather than requesting a future migration. ADR 0014places persistent decisions behind the panel API; this record says which ofthose decisions could ever travel. This is not a multi-host dashboard. Sharing an administrative decision is notthe same feature as showing another machine's containers. Decision Share what a person decided about a project. Never share what a machineobserved. Five kinds of state Every fact the gateway holds on 2026-09-01, including what issues #1, #4 and#6 added. Kind Examples Source today Shareable Runtime container ids, state, health, uptime, published ports, networks, mounts, working_dir, access-bridge ports, Traefik routers and their live status, Docker logs and stats Docker Engine, Traefik API, kernel-allocated ports Never. Re-derived in milliseconds and wrong anywhere else Instance bind address, domains, TLS mode, profile, ACME, TS_AUTHKEY, CF_DNS_API_TOKEN, panel auth hash, COMPOSE_PROJECT_NAME of the gateway itself, database password .env Never. Host-specific, and half of it is secret Project display name, description, primary service, hidden services, ordering, notes, repo_url / repo_subpath / slug PostgreSQL (projects, project_settings, service_settings) Yes, with the identity rules below User theme, default page, table density PostgreSQL (settings) Yes, and low stakes either way Shareable, with translation hostname alias PostgreSQL service_settings.alias plus a generated Traefik file Partly — see aliases Git snapshots under state/git/ and host metrics under state/metrics/ areruntime observations collected on the host(ADR 0010). They are not shareable as asource of truth; repo_url extracted from a Git file is a portablecoordinate, which is a different column doing a different job. Share records (temporary extra hostnames with expiry) are instance-scoped:they bind a live container name to a host-specific domain. They are notproject decisions. A future GitHub issue projection (ADR 0018)is a cache of a remote source of truth, not a decision and not a Dockerobservation. It is not shared between instances by copying rows; eachinstance talks to GitHub itself. Two panels holding the same Appinstallation is a concurrency problem for issue #20, not a sync feature. COMPOSE_PROJECT_NAME of a consumer environment is local identity, notshareable on its own: storefront on the laptop and storefront on the VPSare probably the same project, and storefront-issue59 is a worktree thatmay not exist remotely at all. Project identity Not a distributed identity system. A local id plus portable coordinates: Issue #4 delivered this shape. (repo_url, repo_subpath) is the portablecoordinate. slug is the fallback for a project with no Git, and the manualassociation two people (or two gateways) can agree on. The worktree case: storefront and storefront-issue59 share a repo_urland differ in compose_project. Sharing, if it is ever built, operates onthe repository coordinate. Per-environment overrides stay local, which iswhat issue #5 already decided for the right reasons.ADR 0013 already forbids two worktreesfrom inheriting each other's aliases. Nothing merges. repo_url is a coordinate, not a key.compose_project stays the local identity. repo_url normalisation A pure function. Inputs that denote the same repository must compare equal.Covered forms, with examples: Input Normalised git@github.com:acme/storefront.git github.com/acme/storefront https://github.com/acme/storefront github.com/acme/storefront https://github.com/acme/storefront.git github.com/acme/storefront ssh://git@github.com/acme/storefront.git github.com/acme/storefront git@gitlab.com:acme/storefront.git gitlab.com/acme/storefront https://git.example.com/acme/storefront.git git.example.com/acme/storefront empty / no remote null Rules: Strip the scheme (git+ssh://, ssh://, https://, http://). Strip a leading git@ user and rewrite host:path to host/path. Strip a trailing .git. Strip a trailing slash. Lowercase the host. Do not lowercase the path: some forges arecase-sensitive, GitHub is not, and over-normalising merges distinctrepositories on a self-hosted forge. Drop a trailing .wiki.git or /issues suffix; those are not therepository. Submodules are separate repositories with their own remotes; a consumerproject's repo_url is the superproject, and repo_subpath names apackage inside it, not a submodule. No remote, or a remote that is a local path, yields null. slug thencarries identity if a person supplies one. The function belongs in packages/core once the CLI and the panel bothneed it. Until then it may live next to the Git collector. It is testedwith a fixture table covering every row above. Instance identity Issue #4 created a singleton instance row: a generated UUID that neverchanges, a human-chosen name (default portta), created_at andupdated_at. That is enough. portta status --json (issue #9) should includeinstance.id and instance.name so two gateways can be told apart the daythere are two. The UUID is local. It is not a tracking identifier, andnothing transmits it anywhere today. updated_by_instance on eligible rows is not added now. It is a synccolumn, and this record forbids shipping sync machinery. Aliases are labels, not hostnames shop.localhost on a laptop; *.dev.example.com on a VPS. A shared aliascannot be a hostname. The stored value is a DNS label: shop. Each instance renders it againstits own PORTTA_DOMAIN, PRIVATE_DOMAIN or PUBLIC_DOMAIN. Thelaptop serves shop.localhost, the VPS serves shop.dev.example.com, andnothing shared contains a hostname. Issue #5's catalogue already matches this: SERVICE_KEYS.alias is alowercase DNS label (letters, digits, hyphens, at most 63 characters), not aFQDN. Keep it that way. Rendering the hostname is instance-local and happenswhen the Traefik file is written, not when the preference is stored. The four architectures, if synchronisation is ever built Central service Central PostgreSQL Peer-to-peer Hybrid Shape Both instances call one control plane Both connect to one database Each keeps its own, exchanges changes Local database, selected records via a shared layer Complexity High — a service to build, host, secure, upgrade Low to build Very high — conflict resolution, vector clocks or CRDTs Medium Offline Degraded or broken Broken — the laptop stops working on a plane Full Full Security An authenticated API, tokens, rotation A database open to the internet, or a tunnel Mutual auth between instances One narrow authenticated surface Conflicts Server decides Not applicable The hard part Last-write-wins on a small record set Operational cost A service to run forever A database to run forever None beyond the instances Small Verdict Overkill for one person Disqualified by offline Disproportionate Recommended, if ever Two of the four are eliminated on requirements rather than taste: Central PostgreSQL fails the basic requirement. The gateway's wholepurpose is a local development environment; one that stops working withouta network connection is a worse tool than one that never synced. Peer-to-peer is a distributed-systems project. Conflict resolution overan arbitrary record set is not a side feature of a development gateway. If synchronisation is ever built, it should be hybrid and boring: each instance keeps its own PostgreSQL and works fully offline; only project and user state is eligible, and only for projects with arepo_url or an explicit slug; every eligible row already carries updated_at; a future sync change mayadd updated_by_instance; reconciliation is last-write-wins per field, with both values shown inthe panel when they differ — a person resolving a name clash is cheaperthan a merge algorithm; the transport is a push/pull command, not a daemon:portta sync push / pull, over the panel's existing authenticatedAPI (ADR 0012) reached over thetailnet; an append-only event log is not needed for last-write-wins on a fewdozen rows. Last-write-wins loses data by design. Acceptable for a friendly name. Itmust stay written down rather than discovered in a support thread. The cheapest first experiment Sync by committing state to a Git repository — a ~/.portta repopushed and pulled — is not the plan. It is the cheapest prototype if thisis ever tried: conflict resolution, history and transport all come free, andthe audience already uses Git constantly. It turns every preference changeinto a commit and needs a repository provisioned per user, which is why itis recorded rather than chosen. Asks already delivered, and asks that remain Issue Ask Status on 2026-09-01 #3 Classify state five ways, not two; record the identity model This ADR #4 repo_url, repo_subpath, slug nullable on projects; an instance table with a UUID; updated_at on project- and user-scoped rows Delivered. Defined in packages/db/src/schema #5 Store the alias as a label, render the hostname per instance Catalogue already stores a label (SERVICE_KEYS.alias). Rendering remains instance-local when the Traefik file is written #9 --json output identifies the instance; the CLI never opens its own database connection Still open. ADR 0014 already forbids the database connection Consequences Two gateways still know nothing about each other. Anyone proposing synchronisation later starts from a page that alreadysays what may be shared, what identifies a project across machines, why acentral database was ruled out, and why last-write-wins is enough. If it is never built, the cost was three nullable columns and one tablethat made portta status --json able to say which gateway answered. Runtime state must not leak into \"shareable\". A review check on every newcolumn: is this a decision a person made, or an observation a machinemade? Issue #20 (a reconciliation loop plus a webhook receiver) makes thissharper: two panels holding the same GitHub App installation would bothreconcile the same repositories. That issue does not solve sharing; itrequires github_sync_state writes to be safe under concurrency. What this record forbids No synchronisation code, no sync table, no updated_by_instance column, andno network call ships from the change that accepts this ADR.","headings":[{"id":"0016-state-that-could-be-shared-and-what-must-never-be","text":"0016. State that could be shared, and what must never be","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":10},{"id":"decision","text":"Decision","level":2,"line":32},{"id":"five-kinds-of-state","text":"Five kinds of state","level":3,"line":37},{"id":"project-identity","text":"Project identity","level":3,"line":71},{"id":"repo_url-normalisation","text":"repo_url normalisation","level":3,"line":99},{"id":"instance-identity","text":"Instance identity","level":3,"line":135},{"id":"aliases-are-labels-not-hostnames","text":"Aliases are labels, not hostnames","level":3,"line":149},{"id":"the-four-architectures-if-synchronisation-is-ever-built","text":"The four architectures, if synchronisation is ever built","level":3,"line":164},{"id":"the-cheapest-first-experiment","text":"The cheapest first experiment","level":3,"line":204},{"id":"asks-already-delivered-and-asks-that-remain","text":"Asks already delivered, and asks that remain","level":3,"line":213},{"id":"consequences","text":"Consequences","level":2,"line":222},{"id":"what-this-record-forbids","text":"What this record forbids","level":2,"line":238}],"kind":"markdown"},{"slug":"adr/0017-no-docker-sdk","title":"0017. The panel speaks the Docker Engine API directly","description":"0017. The panel speaks the Docker Engine API directly.","source":"docs/development/adr/0017-no-docker-sdk.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0017-no-docker-sdk","markdown":"# 0017. The panel speaks the Docker Engine API directly\n\n**Status:** Accepted\n\n## Context\n\nThe panel needs a small part of the Docker Engine API to discover containers,\nshow logs and metrics, react to events, perform explicit lifecycle actions and\ncreate the single constrained bridge shape used by temporary TCP access.\n\nThat client is also a security boundary. Every request passes through the\nallowlist in `apps/web/src/server/docker/allowlist.ts` before it reaches the panel's\nfiltered socket proxy. The client has no generic request method exposed to\nroute handlers. Container removal always preserves volumes and links, and\nbridge creation explicitly denies binds, mounts, privileges and added\ncapabilities. [ADR 0008](0008-web-panel-socket-proxy.md) describes the two\nlayers together.\n\n`dockerode` is a mature Docker SDK, but its purpose is to expose nearly the\nwhole API. It includes operations this panel deliberately cannot perform,\nincluding exec, image and volume management, prune, archives, builds and\nremote daemon transports. Wrapping it would retain our facade while moving the\nallowlist away from the transport that actually emits requests.\n\nThe production dependency comparison was repeated on 2026-09-01 with Node 24\nand npm 11, using `npm ls --omit=dev --all --parseable` for the panel and an\nisolated `npm install --package-lock-only --omit=dev dockerode@5.0.1`:\n\n| Production tree | Resolved packages, excluding the root |\n|---|---:|\n| Current panel | 12 |\n| `dockerode@5.0.1` alone | 66 |\n\nThe SDK tree still includes SSH, gRPC, Protocol Buffers, tar streaming and\noptional native compilation support that the panel does not use.\n\nThe investigation did find one problem in the existing client: it sent\nunversioned paths. A newer daemon could therefore answer using a response\ncontract newer than the project's supported baseline.\n\n## Decision\n\nThe panel continues to use its purpose-built Docker client and does not add a\ngeneral Docker SDK.\n\nThe client pins every request to Docker Engine API `v1.43`. That is the API\nimplemented by Docker Engine 24, the minimum version declared in\n`docs/compatibility.md`. A newer daemon continues to serve that version, while\nan installation below the supported minimum is already rejected by the\nproject's compatibility checks.\n\nThe transport-level allowlist remains the primary reason for this decision.\nThe dependency measurement supports it, but a smaller SDK would not be enough\non its own if it made the request boundary less explicit.\n\n## Consequences\n\n- A route handler cannot reach an Engine endpoint the allowlist does not name.\n- Tests can inspect the exact versioned URL, query and JSON body emitted by the\n panel rather than only the options passed to another client.\n- The runtime adds no Docker SDK, remote transport, archive or build tooling.\n- We continue maintaining the response types, event parser and Docker log\n demultiplexer used by the panel.\n- New Engine operations require an explicit allowlist entry, a client method\n and tests. API version changes require an intentional code and documentation\n change.\n\n## Reversal conditions\n\nReconsider this decision if at least one of these becomes true:\n\n1. The panel deliberately adds streaming exec or attach, image or volume\n management, BuildKit, or TLS/SSH transport to a remote daemon.\n2. The Engine protocol gains behavior the small client cannot reasonably\n implement or verify.\n3. `ALLOWED_ENDPOINTS` grows materially beyond roughly 25 entries, making the\n hand-written surface more expensive than a maintained SDK.\n\nAny reversal still has to preserve a transport-level allowlist and the\nliteral body guarantees for destructive operations. Convenience alone is not\na reason to widen the panel's Docker authority.\n\n## Alternatives considered\n\n**Adopt dockerode directly.** Rejected because its broad API is the opposite\nof this layer's defining constraint and its unused dependency surface is\nlarge.\n\n**Put dockerode behind the current facade.** Rejected because it keeps the\nfacade, adds the full dependency tree and weakens enforcement from the request\ntransport to call-site convention.\n\n**Use `docker-modem` alone.** Rejected because native `fetch` already provides\nthe transport needed here, while docker-modem still brings remote-transport\nmachinery and no useful domain types.\n\n**Keep unversioned requests.** Rejected. Pinning `v1.43` makes the protocol\ncontract match the project's already documented Docker 24 baseline.\n","text":"0017. The panel speaks the Docker Engine API directly Status: Accepted Context The panel needs a small part of the Docker Engine API to discover containers,show logs and metrics, react to events, perform explicit lifecycle actions andcreate the single constrained bridge shape used by temporary TCP access. That client is also a security boundary. Every request passes through theallowlist in apps/web/src/server/docker/allowlist.ts before it reaches the panel'sfiltered socket proxy. The client has no generic request method exposed toroute handlers. Container removal always preserves volumes and links, andbridge creation explicitly denies binds, mounts, privileges and addedcapabilities. ADR 0008 describes the twolayers together. dockerode is a mature Docker SDK, but its purpose is to expose nearly thewhole API. It includes operations this panel deliberately cannot perform,including exec, image and volume management, prune, archives, builds andremote daemon transports. Wrapping it would retain our facade while moving theallowlist away from the transport that actually emits requests. The production dependency comparison was repeated on 2026-09-01 with Node 24and npm 11, using npm ls --omit=dev --all --parseable for the panel and anisolated npm install --package-lock-only --omit=dev dockerode@5.0.1: Production tree Resolved packages, excluding the root Current panel 12 dockerode@5.0.1 alone 66 The SDK tree still includes SSH, gRPC, Protocol Buffers, tar streaming andoptional native compilation support that the panel does not use. The investigation did find one problem in the existing client: it sentunversioned paths. A newer daemon could therefore answer using a responsecontract newer than the project's supported baseline. Decision The panel continues to use its purpose-built Docker client and does not add ageneral Docker SDK. The client pins every request to Docker Engine API v1.43. That is the APIimplemented by Docker Engine 24, the minimum version declared indocs/compatibility.md. A newer daemon continues to serve that version, whilean installation below the supported minimum is already rejected by theproject's compatibility checks. The transport-level allowlist remains the primary reason for this decision.The dependency measurement supports it, but a smaller SDK would not be enoughon its own if it made the request boundary less explicit. Consequences A route handler cannot reach an Engine endpoint the allowlist does not name. Tests can inspect the exact versioned URL, query and JSON body emitted by thepanel rather than only the options passed to another client. The runtime adds no Docker SDK, remote transport, archive or build tooling. We continue maintaining the response types, event parser and Docker logdemultiplexer used by the panel. New Engine operations require an explicit allowlist entry, a client methodand tests. API version changes require an intentional code and documentationchange. Reversal conditions Reconsider this decision if at least one of these becomes true: The panel deliberately adds streaming exec or attach, image or volumemanagement, BuildKit, or TLS/SSH transport to a remote daemon. The Engine protocol gains behavior the small client cannot reasonablyimplement or verify. ALLOWED_ENDPOINTS grows materially beyond roughly 25 entries, making thehand-written surface more expensive than a maintained SDK. Any reversal still has to preserve a transport-level allowlist and theliteral body guarantees for destructive operations. Convenience alone is nota reason to widen the panel's Docker authority. Alternatives considered Adopt dockerode directly. Rejected because its broad API is the oppositeof this layer's defining constraint and its unused dependency surface islarge. Put dockerode behind the current facade. Rejected because it keeps thefacade, adds the full dependency tree and weakens enforcement from the requesttransport to call-site convention. Use docker-modem alone. Rejected because native fetch already providesthe transport needed here, while docker-modem still brings remote-transportmachinery and no useful domain types. Keep unversioned requests. Rejected. Pinning v1.43 makes the protocolcontract match the project's already documented Docker 24 baseline.","headings":[{"id":"0017-the-panel-speaks-the-docker-engine-api-directly","text":"0017. The panel speaks the Docker Engine API directly","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":40},{"id":"consequences","text":"Consequences","level":2,"line":55},{"id":"reversal-conditions","text":"Reversal conditions","level":2,"line":67},{"id":"alternatives-considered","text":"Alternatives considered","level":2,"line":82}],"kind":"markdown"},{"slug":"adr/0018-github-access-lives-in-the-panel","title":"0018. GitHub access lives in the panel, through a GitHub App","description":"0018. GitHub access lives in the panel, through a GitHub App.","source":"docs/development/adr/0018-github-access-lives-in-the-panel.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0018-github-access-lives-in-the-panel","markdown":"# 0018. GitHub access lives in the panel, through a GitHub App\n\n**Status:** Accepted, amended 2026-09-02 by the audit in #25\n\nIssue #17 asked for ADR 0013. That number was already taken by\n[ADR 0013](0013-what-the-panel-persists.md) (what the panel persists) and\n0017 by [ADR 0017](0017-no-docker-sdk.md). This is 0018.\n\nThis record decides where GitHub access runs and what it costs. **No App,\nno dependency, no route and no table ships from the change that accepts\nit.** Implementation is issues #18–#22.\n\n## Context\n\n[ADR 0010](0010-git-collected-on-the-host.md) considered calling the GitHub\nAPI from the panel and rejected it: a long-lived credential in a file the\npanel itself can write, egress from a container that has none today, and\nour own rate-limit accounting. It closed with:\n\n> Read-only, in both directions. No checkout, merge, rebase, reset, stash,\n> fetch or push; no PR approval or merge; **no webhook**; no write to any\n> repository. The gateway observes environments, it does not drive them.\n\nA GitHub App that reads and writes issues, receives webhooks and moves\ncards on a board reverses that last sentence. The repository's own\nconvention is to settle that on paper first.\n\nThe ambition is a development control plane: projects, repositories,\nGitHub issues, workspaces, Docker environments and, later, coding agents.\nThis record says only enough about agents and worktrees to avoid\nforeclosing them. It does not design a task runner.\n\nChecked against GitHub's REST API on 2026-09-01, `X-GitHub-Api-Version:\n2026-03-10`. Sub-issues, organisation issue types and issue field values\nare generally available endpoints, not previews. Installation access\ntokens expire after one hour. From April 2026 GitHub has been rolling out\na longer stateless token format (`ghs_APPID_JWT`); nothing here may assume\na 40-character token.\n\n## What this record contradicts in earlier ADRs\n\n| Record | Sentence | This record |\n|---|---|---|\n| ADR 0010 | \"Calling the GitHub API from the panel with a token in `.env`\" is rejected | **Stands** for a PAT. A GitHub App private key is a different secret class, and it is not a `.env` value |\n| ADR 0010 | Local `git` is collected on the host; the panel only reads `state/git/` | **Stands** |\n| ADR 0010 | No project directory is mounted; `EXEC` stays off; no `git` in the panel image | **Stands** |\n| ADR 0010 | `gh` is optional on top of local `git` for open pull requests | **Stands as the fallback** when no App is configured. The App becomes the source for issue and pull-request *state* once it is |\n| ADR 0010 | \"Read-only, in both directions… no webhook; no write to any repository\" | **Superseded for GitHub issues, sub-issues, issue types/fields and pull-request state.** Local working trees remain read-only |\n| ADR 0010 | \"The gateway observes environments, it does not drive them\" | **Stands for Git working trees and Docker.** It does not stand for GitHub issues |\n| ADR 0008 | The panel's socket proxy and allowlist | **Stands.** GitHub is HTTP egress, not a Docker capability |\n| ADR 0012 | The panel is loopback by default; public exposure is refused | **Stands.** Webhooks never require public exposure |\n| ADR 0013 | Persist a decision, never an observation | **Amended:** a projection of a remote source of truth is a third category, below |\n| ADR 0001 | The gateway does not mount consumer project directories | **Stands** |\n\nADR 0010 is marked amended, not replaced. Everything it decided about\n*local* Git still holds.\n\n## Decision\n\n> **Local Git stays on the host. Issues move to a GitHub App in the\n> panel.** `portta repos scan` keeps producing branch, HEAD, dirty\n> counts and ahead/behind from local `git` — no network, every forge,\n> works offline. The App takes over issues, sub-issues, issue types,\n> issue fields and pull-request state, because those are not derivable\n> from a working directory and because writing them is the point.\n\nThe host-side `gh pr list` path remains the documented fallback for a\npanel with no App configured, exactly as ADR 0010 described it.\n\n### 1. Egress is opt-in in code, on a network the panel already has\n\n`docker/compose/features/web.yaml` already attaches the panel to the shared `gateway`\nnetwork, which is **not** `internal`. Docker therefore already gives the\ncontainer a default route. Today's \"no egress\" is a property of the\nprocess: the only outbound `fetch` is to Traefik. It is not a property of\nthe network.\n\nEnabling the App uses that existing route. No new network. Do not make\n`gateway` internal; that would break Traefik routing to the panel.\n\n**The gate is in code:** if the App is not configured, the panel does not\ncall `api.github.com`. Configuration is what turns the existing route\ninto GitHub egress, not a Compose overlay.\n\nCost: a container that may be routed over a VPN talks to the internet\nonce the integration is on. On the public profile the panel remains\ninbound-refused ([ADR 0012](0012-routed-panel-access-requires-authentication.md));\noutbound to `api.github.com` from a VPS is the same class of call as\nfrom a laptop. `doctor` should report whether GitHub egress is\nconfigured, not whether a socket can connect.\n\n### 2. Reconciliation is the baseline; webhooks are an optimisation\n\nA loopback panel cannot receive a GitHub delivery. Correctness must not\ndepend on one.\n\nA reconciliation loop, using `updated_at` cursors rather than full\nsweeps, is the source of freshness. Webhooks become a supported add-on\nonly when all of these are true:\n\n- the panel is already published on a path GitHub can reach (the VPN\n overlay, not loopback);\n- the operator has configured a webhook secret;\n- every delivery is signature-validated before it is parsed.\n\nPublic exposure of the panel stays refused. A webhook never becomes a\nreason to bind `0.0.0.0` or to attach the panel to the public\nentrypoints.\n\nDefault: no webhooks. A loopback panel is correct, just slower to notice\na change made on github.com.\n\nCost: the default posture stays honest, and a laptop without a\npublished URL still works. The cost of the add-on is an inbound URL on\na deployment the user already opted into, plus a webhook secret.\n\nTwo panels holding the same App installation would both reconcile the\nsame repositories. That is a concurrency problem for issue #20, not a\nsharing feature; [ADR 0016](0016-state-that-could-be-shared.md) already\nsays GitHub projections are not copied between instances.\n\n### 3. The private key is not a `.env` value the panel can write\n\n| Secret | Where it lives | What the API may return |\n|---|---|---|\n| App id | `.env` (`PORTTA_RUNTIME_GITHUB_APP_ID`), not marked secret | the id |\n| Private key | a host file, mode `600`, mounted read-only; only its path is in `.env` | whether the path is set, never the PEM |\n| Webhook secret | the same shape as the private key: a file, path in `.env` | whether it is set |\n| Installation tokens | process memory, with their expiry | nothing. Never persisted, never logged, never sent to the browser |\n\n`PATCH /api/config` must not be able to write a PEM or a webhook secret\ninto `.env`. The existing `secret: true` fields (`TS_AUTHKEY`,\n`CF_DNS_API_TOKEN`, `PORTTA_RUNTIME_DB_PASSWORD`)\nare write-only strings in `.env`; that pattern is **not** sufficient for\na GitHub App private key. The shape is the one `state/git` already uses:\nthe panel reads a file it cannot usefully overwrite through Settings.\n\nInstallation tokens are minted from the private key, live about an hour,\nand are discarded at expiry or process restart. Do not store them in\nPostgreSQL. Do not assume they are 40 characters long.\n\nLogs may contain App id, installation id, repository nwo and HTTP status.\nThey may not contain PEMs, tokens, webhook secrets or raw delivery\npayloads that include them.\n\n### 4. A projection is a third category\n\n[ADR 0013](0013-what-the-panel-persists.md) said persist a decision,\nnever an observation. A projection of GitHub issues is neither. The\namended rule is:\n\n> Persist a decision, never an observation. The panel may also cache\n> what a remote source of truth owns, provided every projected row\n> records where it came from and when, the UI shows that age, and\n> nothing cached is ever the only copy.\n\nThe projection schema is defined in `packages/db/src/schema/github.ts`.\n\n### 5. Source of truth\n\n| Fact | Owner |\n|---|---|\n| Issue title, body, state, labels, assignees, milestone, type, field values, sub-issue links, pull-request state | GitHub |\n| Branch, HEAD, dirty counts, ahead/behind | Local `git`, collected on the host (ADR 0010) |\n| Containers, health, URLs, networks | Docker / Traefik on this host |\n| Which GitHub repositories a Portta project owns | Portta (a decision) |\n| Which environments a Portta project has adopted | Portta (a decision) |\n| A link from an issue to an environment | Portta (a decision) |\n| Theme, aliases, display names | Portta (a decision) |\n\nThe panel never edits GitHub by writing only to PostgreSQL. A board\ncolumn that means \"closed\" closes the issue on GitHub, then the\nprojection follows. A local ranking or pin is a gateway decision and is\nlabelled as one.\n\nDocumented for readers who will not open an ADR: [docs/github.md](../../product/guides/github.md).\n\n### 6. Project, environment, repository\n\nNames, not columns. The migration is issue #19.\n\n| Entity | What it is | Today |\n|---|---|---|\n| **Environment** | One Compose project on this host. Local identity is `COMPOSE_PROJECT_NAME` ([ADR 0006](0006-compose-project-name-as-namespace.md)). Portable coordinates are `repo_url` / `repo_subpath` / `slug` ([ADR 0016](0016-state-that-could-be-shared.md)) | The `projects` table |\n| **Project** | A grouping the user creates. It owns repositories and adopts environments | Does not exist |\n| **Repository** | A GitHub repository bound to a project | A nullable coordinate on the environment row |\n\nToday's `projects` row is an environment. Issue #19 renames in the\nschema; this record forbids treating `compose_project` as the Dev\nGateway project.\n\nThe label `portta.project` remains a **discovery hint** for grouping\nworktrees under one heading, as ADR 0010 defined it. It does not create\nthe new project entity, and the panel must not infer one from it\nautomatically. A person creates a project and may adopt environments\nwhose label matches. Two grouping mechanisms that silently disagree\nwould be worse than one explicit grouping.\n\nA project owning N repositories, and later a group owning N projects,\nfits this model. Agent runs and parallel worktrees attach to\n*environments*, which already exist per worktree. That is enough to\navoid foreclosing them.\n\n### 7. Degradation\n\n| Condition | Panel behaviour |\n|---|---|\n| No App configured | Docker-backed pages unchanged. Git cards stay on the host snapshot. Issue surfaces are absent, not broken |\n| GitHub unreachable, 5xx, timeout | Serve the projection, show its age, do not fail the rest of the panel |\n| Rate limit exhausted | Same as unreachable. The remaining budget is visible on a diagnostic surface. Reconciliation backs off |\n| Invalid private key / installation | Configuration error on the GitHub surface; everything else stays up |\n| Read-only mode | GitHub writes are refused the same way local writes are. The UI does not offer them |\n\nPostgreSQL remaining a soft dependency ([ADR 0013](0013-what-the-panel-persists.md))\nis unchanged. A GitHub surface that needs the projection returns a clear\n503 if the database is down; Overview does not.\n\nRate limits: every client keeps and exposes the remaining budget from\nGitHub's response headers. Reconciliation uses cursors. Exhaustion\ndegrades to the projection rather than to an error page.\n\n### 8. Read-only mode covers writes that leave the host\n\n`PORTTA_WEB_READ_ONLY` already refuses unsafe HTTP methods in\n`apps/web/src/server/app.ts`. That refusal includes a write that would land\non github.com. A read-only panel may refresh a projection. It may not\ncreate, edit, close, comment, reparent or relabel an issue, and it may\nnot approve or merge a pull request.\n\n### 9. Dependency budget\n\nMeasured 2026-09-01 with Node 22 and npm, `npm install --package-lock-only\n--omit=dev`, counting resolved packages excluding the measuring root.\nThe panel's current production tree is **12**\n([ADR 0017](0017-no-docker-sdk.md)).\n\n| Package | Resolved packages | Verdict |\n|---|---:|---|\n| `octokit` (meta) | 63 | **Forbidden.** It more than quintuples the tree |\n| `@octokit/app` | 44 | Rejected as the default. Almost four times the panel |\n| `@octokit/auth-app` | 28 | Rejected as the default, same reason |\n| `@octokit/rest` | 20 | Rejected as the default |\n| `@octokit/request` | 8 | Acceptable only if a purpose-built client proves worse |\n| `@octokit/webhooks` | 6 | **Allowed when webhooks ship.** Signature verification is easy to get wrong |\n\n**Default: a purpose-built GitHub App client using `fetch`**, the same\nshape as the Docker client. JWT minting (RS256) and\n`POST /app/installations/{id}/access_tokens` are small, well-specified\ntasks. REST calls are `fetch` with `X-GitHub-Api-Version` set. Resulting\nruntime until webhooks: **12**. With `@octokit/webhooks`: **18**.\n\nThe GitHub integration must not more than double the production tree.\nThe meta-package is never added to close a gap that `fetch` can close.\n\nIssue #18 names the exact packages it adds and updates this budget if\nthe count moves.\n\n## Amendment, 2026-09-02: the audit in #25\n\nIssue #25 read the delivered integration against this record, decision by\ndecision. Three of them were deliberate omissions carried forward from the\nbrief without ever being measured, and one sentence in this record had become\nuntrue. The verdicts:\n\n### Octokit: **keep the purpose-built client**, and here is the measurement\n\n`docs/github.md` set the test itself — *\"it is revisited if the surface grows\npast what is honest to maintain\"* — so this is measured rather than inherited.\n\n| | Lines | What Octokit would replace |\n|---|---:|---|\n| `integrations/github/app.ts` | 156 | ~80: the RS256 JWT and the installation-token cache (`@octokit/auth-app`) |\n| `integrations/github/client.ts` | 167 | ~60: Link pagination and rate-limit accounting (`@octokit/plugin-paginate-rest`) |\n| The rest | 87 | Nothing. Typed errors (`GitHubUnavailable`, `GitHubRateLimited`, `GitHubForbidden`) are the panel's *degradation contract*, and no client library has an opinion about them |\n\nSo Octokit would replace roughly **140 lines** — both of them already written\nand tested. Measured 2026-09-02 with `npm install @octokit/rest@22\n@octokit/auth-app@8`: **25 packages, 19 MB on disk**, against a panel\nproduction tree of **18 resolved packages**. It more than doubles the tree the\n§9 budget forbids doubling, to delete 140 lines that work.\n\n**Verdict: keep.** The reversal condition is unchanged and now has a number\nattached: revisit if the transport half grows past ~300 lines, or if a needed\nendpoint turns out to be one `fetch` cannot reach.\n\n### GraphQL and Projects v2: **amend — a conditional fourth source**\n\nTwo questions with different answers. *Do we need GraphQL?* Only for Projects v2\nfields; sub-issues and issue types are REST and already in use. *Do we need\nProjects v2?* Only if a repository Portta touches uses a Project as its board —\nand if it does, Portta's `status:*` labels become the second source of truth\nthis record exists to forbid.\n\n`metadata.ts` already has the seam: `MetadataSource` is `fields | labels |\nnone`. The extension point is recorded here as **`project`**, a fourth source\nthat is built *only when a real repository demands it*, and `docs/github.md`\ndescribes it as a documented extension point rather than a plan. Nothing is\nimplemented, and the GraphQL client stays unwritten until then.\n\n**Verdict: amend.** The omission stands, with the seam and the trigger named.\n\n### Comments: **amend — never projected, and writable through**\n\nThe original reasoning is about *projecting* comments: they are large, they\nchange often, and a link beats a worse reader. It does not argue against\n*writing* one.\n\n- **Not projected** stands: no `github_issue_comments` table, no sync path,\n reading is a link to GitHub. `apps/web/tests/server/issues.test.ts` asserts\n the table does not exist.\n- **A write-through `POST` is allowed**, going straight to GitHub and returning\n what GitHub returned, exactly as `PATCH /api/issues/:id` already does. That\n gives an agent \"add a comment\" without creating a cache to keep in step. #26\n builds it; the specification is in the scope comment on #25.\n\n**`issue_comment` is removed from `HANDLED_EVENTS`.** It was listed there and\nnothing acted on it: a delivery bought a whole repository reconciliation to\nrefresh one `updated_at`, on the event that fires most often in an active\nrepository. Wiring it to a projection would contradict the paragraph above; the\nhonest resolution is to stop asking GitHub to send it.\n\n### §2 was describing a timer that did not exist\n\nThis record says reconciliation is the baseline and webhooks the optimisation,\nand `docs/github.md` said reconciliation runs *\"on demand, and on a timer\"*. The\ntimer did not exist. `grep -rn setInterval apps/web/src/server` found nothing\noutside tests, and the only trigger was `POST /api/integrations/github/sync` —\nso on the configuration this record calls the *default*, a loopback panel that\ncannot receive deliveries, freshness depended entirely on somebody pressing a\nbutton.\n\nThat is a correctness gap in the exact posture the record defends, so the timer\nwas built rather than the sentence deleted:\n`integrations/github/sync/schedule.ts`, calling the same bounded `reconcile` the\nbutton calls, every `GITHUB_SYNC_INTERVAL_MINUTES` (default 15, `0` to turn it\noff on a panel that does receive webhooks). No table, no route, no dependency,\nand a tick that arrives while the previous pass is still running is skipped.\n\n\n## Consequences\n\n- Issues #18–#22 can each be reviewed as ordinary work: the App and the\n repository projection, projects that own repositories, the issue\n projection and sync, the board, the issue↔environment links.\n- Local Git does not become worse. A host without a GitHub App still has\n branch, dirty state and `gh`-sourced pull requests.\n- The panel gains a new class of secret and, when configured, a new\n class of egress. Both are explicit, both are off by default, both are\n visible to `doctor`.\n- An Accepted ADR was superseded in named sentences. The next reversal\n of 0010 is cheaper because the remaining sentences are still true.\n- Agents, worktrees and a future task runner are not designed here.\n They attach to environments.\n","text":"0018. GitHub access lives in the panel, through a GitHub App Status: Accepted, amended 2026-09-02 by the audit in #25 Issue #17 asked for ADR 0013. That number was already taken byADR 0013 (what the panel persists) and0017 by ADR 0017. This is 0018. This record decides where GitHub access runs and what it costs. No App,no dependency, no route and no table ships from the change that acceptsit. Implementation is issues #18–#22. Context ADR 0010 considered calling the GitHubAPI from the panel and rejected it: a long-lived credential in a file thepanel itself can write, egress from a container that has none today, andour own rate-limit accounting. It closed with: Read-only, in both directions. No checkout, merge, rebase, reset, stash,fetch or push; no PR approval or merge; no webhook; no write to anyrepository. The gateway observes environments, it does not drive them. A GitHub App that reads and writes issues, receives webhooks and movescards on a board reverses that last sentence. The repository's ownconvention is to settle that on paper first. The ambition is a development control plane: projects, repositories,GitHub issues, workspaces, Docker environments and, later, coding agents.This record says only enough about agents and worktrees to avoidforeclosing them. It does not design a task runner. Checked against GitHub's REST API on 2026-09-01, X-GitHub-Api-Version: 2026-03-10. Sub-issues, organisation issue types and issue field valuesare generally available endpoints, not previews. Installation accesstokens expire after one hour. From April 2026 GitHub has been rolling outa longer stateless token format (ghs_APPID_JWT); nothing here may assumea 40-character token. What this record contradicts in earlier ADRs Record Sentence This record ADR 0010 \"Calling the GitHub API from the panel with a token in .env\" is rejected Stands for a PAT. A GitHub App private key is a different secret class, and it is not a .env value ADR 0010 Local git is collected on the host; the panel only reads state/git/ Stands ADR 0010 No project directory is mounted; EXEC stays off; no git in the panel image Stands ADR 0010 gh is optional on top of local git for open pull requests Stands as the fallback when no App is configured. The App becomes the source for issue and pull-request state once it is ADR 0010 \"Read-only, in both directions… no webhook; no write to any repository\" Superseded for GitHub issues, sub-issues, issue types/fields and pull-request state. Local working trees remain read-only ADR 0010 \"The gateway observes environments, it does not drive them\" Stands for Git working trees and Docker. It does not stand for GitHub issues ADR 0008 The panel's socket proxy and allowlist Stands. GitHub is HTTP egress, not a Docker capability ADR 0012 The panel is loopback by default; public exposure is refused Stands. Webhooks never require public exposure ADR 0013 Persist a decision, never an observation Amended: a projection of a remote source of truth is a third category, below ADR 0001 The gateway does not mount consumer project directories Stands ADR 0010 is marked amended, not replaced. Everything it decided aboutlocal Git still holds. Decision Local Git stays on the host. Issues move to a GitHub App in thepanel. portta repos scan keeps producing branch, HEAD, dirtycounts and ahead/behind from local git — no network, every forge,works offline. The App takes over issues, sub-issues, issue types,issue fields and pull-request state, because those are not derivablefrom a working directory and because writing them is the point. The host-side gh pr list path remains the documented fallback for apanel with no App configured, exactly as ADR 0010 described it. 1. Egress is opt-in in code, on a network the panel already has docker/compose/features/web.yaml already attaches the panel to the shared gatewaynetwork, which is not internal. Docker therefore already gives thecontainer a default route. Today's \"no egress\" is a property of theprocess: the only outbound fetch is to Traefik. It is not a property ofthe network. Enabling the App uses that existing route. No new network. Do not makegateway internal; that would break Traefik routing to the panel. The gate is in code: if the App is not configured, the panel does notcall api.github.com. Configuration is what turns the existing routeinto GitHub egress, not a Compose overlay. Cost: a container that may be routed over a VPN talks to the internetonce the integration is on. On the public profile the panel remainsinbound-refused (ADR 0012);outbound to api.github.com from a VPS is the same class of call asfrom a laptop. doctor should report whether GitHub egress isconfigured, not whether a socket can connect. 2. Reconciliation is the baseline; webhooks are an optimisation A loopback panel cannot receive a GitHub delivery. Correctness must notdepend on one. A reconciliation loop, using updated_at cursors rather than fullsweeps, is the source of freshness. Webhooks become a supported add-ononly when all of these are true: the panel is already published on a path GitHub can reach (the VPNoverlay, not loopback); the operator has configured a webhook secret; every delivery is signature-validated before it is parsed. Public exposure of the panel stays refused. A webhook never becomes areason to bind 0.0.0.0 or to attach the panel to the publicentrypoints. Default: no webhooks. A loopback panel is correct, just slower to noticea change made on github.com. Cost: the default posture stays honest, and a laptop without apublished URL still works. The cost of the add-on is an inbound URL ona deployment the user already opted into, plus a webhook secret. Two panels holding the same App installation would both reconcile thesame repositories. That is a concurrency problem for issue #20, not asharing feature; ADR 0016 alreadysays GitHub projections are not copied between instances. 3. The private key is not a .env value the panel can write Secret Where it lives What the API may return App id .env (PORTTA_RUNTIME_GITHUB_APP_ID), not marked secret the id Private key a host file, mode 600, mounted read-only; only its path is in .env whether the path is set, never the PEM Webhook secret the same shape as the private key: a file, path in .env whether it is set Installation tokens process memory, with their expiry nothing. Never persisted, never logged, never sent to the browser PATCH /api/config must not be able to write a PEM or a webhook secretinto .env. The existing secret: true fields (TS_AUTHKEY,CF_DNS_API_TOKEN, PORTTA_RUNTIME_DB_PASSWORD)are write-only strings in .env; that pattern is not sufficient fora GitHub App private key. The shape is the one state/git already uses:the panel reads a file it cannot usefully overwrite through Settings. Installation tokens are minted from the private key, live about an hour,and are discarded at expiry or process restart. Do not store them inPostgreSQL. Do not assume they are 40 characters long. Logs may contain App id, installation id, repository nwo and HTTP status.They may not contain PEMs, tokens, webhook secrets or raw deliverypayloads that include them. 4. A projection is a third category ADR 0013 said persist a decision,never an observation. A projection of GitHub issues is neither. Theamended rule is: Persist a decision, never an observation. The panel may also cachewhat a remote source of truth owns, provided every projected rowrecords where it came from and when, the UI shows that age, andnothing cached is ever the only copy. The projection schema is defined in packages/db/src/schema/github.ts. 5. Source of truth Fact Owner Issue title, body, state, labels, assignees, milestone, type, field values, sub-issue links, pull-request state GitHub Branch, HEAD, dirty counts, ahead/behind Local git, collected on the host (ADR 0010) Containers, health, URLs, networks Docker / Traefik on this host Which GitHub repositories a Portta project owns Portta (a decision) Which environments a Portta project has adopted Portta (a decision) A link from an issue to an environment Portta (a decision) Theme, aliases, display names Portta (a decision) The panel never edits GitHub by writing only to PostgreSQL. A boardcolumn that means \"closed\" closes the issue on GitHub, then theprojection follows. A local ranking or pin is a gateway decision and islabelled as one. Documented for readers who will not open an ADR: docs/github.md. 6. Project, environment, repository Names, not columns. The migration is issue #19. Entity What it is Today Environment One Compose project on this host. Local identity is COMPOSE_PROJECT_NAME (ADR 0006). Portable coordinates are repo_url / repo_subpath / slug (ADR 0016) The projects table Project A grouping the user creates. It owns repositories and adopts environments Does not exist Repository A GitHub repository bound to a project A nullable coordinate on the environment row Today's projects row is an environment. Issue #19 renames in theschema; this record forbids treating compose_project as the DevGateway project. The label portta.project remains a discovery hint for groupingworktrees under one heading, as ADR 0010 defined it. It does not createthe new project entity, and the panel must not infer one from itautomatically. A person creates a project and may adopt environmentswhose label matches. Two grouping mechanisms that silently disagreewould be worse than one explicit grouping. A project owning N repositories, and later a group owning N projects,fits this model. Agent runs and parallel worktrees attach toenvironments, which already exist per worktree. That is enough toavoid foreclosing them. 7. Degradation Condition Panel behaviour No App configured Docker-backed pages unchanged. Git cards stay on the host snapshot. Issue surfaces are absent, not broken GitHub unreachable, 5xx, timeout Serve the projection, show its age, do not fail the rest of the panel Rate limit exhausted Same as unreachable. The remaining budget is visible on a diagnostic surface. Reconciliation backs off Invalid private key / installation Configuration error on the GitHub surface; everything else stays up Read-only mode GitHub writes are refused the same way local writes are. The UI does not offer them PostgreSQL remaining a soft dependency (ADR 0013)is unchanged. A GitHub surface that needs the projection returns a clear503 if the database is down; Overview does not. Rate limits: every client keeps and exposes the remaining budget fromGitHub's response headers. Reconciliation uses cursors. Exhaustiondegrades to the projection rather than to an error page. 8. Read-only mode covers writes that leave the host PORTTA_WEB_READ_ONLY already refuses unsafe HTTP methods inapps/web/src/server/app.ts. That refusal includes a write that would landon github.com. A read-only panel may refresh a projection. It may notcreate, edit, close, comment, reparent or relabel an issue, and it maynot approve or merge a pull request. 9. Dependency budget Measured 2026-09-01 with Node 22 and npm, npm install --package-lock-only --omit=dev, counting resolved packages excluding the measuring root.The panel's current production tree is 12(ADR 0017). Package Resolved packages Verdict octokit (meta) 63 Forbidden. It more than quintuples the tree @octokit/app 44 Rejected as the default. Almost four times the panel @octokit/auth-app 28 Rejected as the default, same reason @octokit/rest 20 Rejected as the default @octokit/request 8 Acceptable only if a purpose-built client proves worse @octokit/webhooks 6 Allowed when webhooks ship. Signature verification is easy to get wrong Default: a purpose-built GitHub App client using fetch, the sameshape as the Docker client. JWT minting (RS256) andPOST /app/installations/{id}/access_tokens are small, well-specifiedtasks. REST calls are fetch with X-GitHub-Api-Version set. Resultingruntime until webhooks: 12. With @octokit/webhooks: 18. The GitHub integration must not more than double the production tree.The meta-package is never added to close a gap that fetch can close. Issue #18 names the exact packages it adds and updates this budget ifthe count moves. Amendment, 2026-09-02: the audit in #25 Issue #25 read the delivered integration against this record, decision bydecision. Three of them were deliberate omissions carried forward from thebrief without ever being measured, and one sentence in this record had becomeuntrue. The verdicts: Octokit: keep the purpose-built client, and here is the measurement docs/github.md set the test itself — \"it is revisited if the surface growspast what is honest to maintain\" — so this is measured rather than inherited. Lines What Octokit would replace integrations/github/app.ts 156 ~80: the RS256 JWT and the installation-token cache (@octokit/auth-app) integrations/github/client.ts 167 ~60: Link pagination and rate-limit accounting (@octokit/plugin-paginate-rest) The rest 87 Nothing. Typed errors (GitHubUnavailable, GitHubRateLimited, GitHubForbidden) are the panel's degradation contract, and no client library has an opinion about them So Octokit would replace roughly 140 lines — both of them already writtenand tested. Measured 2026-09-02 with npm install @octokit/rest@22 @octokit/auth-app@8: 25 packages, 19 MB on disk, against a panelproduction tree of 18 resolved packages. It more than doubles the tree the§9 budget forbids doubling, to delete 140 lines that work. Verdict: keep. The reversal condition is unchanged and now has a numberattached: revisit if the transport half grows past ~300 lines, or if a neededendpoint turns out to be one fetch cannot reach. GraphQL and Projects v2: amend — a conditional fourth source Two questions with different answers. Do we need GraphQL? Only for Projects v2fields; sub-issues and issue types are REST and already in use. Do we needProjects v2? Only if a repository Portta touches uses a Project as its board —and if it does, Portta's status:* labels become the second source of truththis record exists to forbid. metadata.ts already has the seam: MetadataSource is fields | labels | none. The extension point is recorded here as project, a fourth sourcethat is built only when a real repository demands it, and docs/github.mddescribes it as a documented extension point rather than a plan. Nothing isimplemented, and the GraphQL client stays unwritten until then. Verdict: amend. The omission stands, with the seam and the trigger named. Comments: amend — never projected, and writable through The original reasoning is about projecting comments: they are large, theychange often, and a link beats a worse reader. It does not argue againstwriting one. Not projected stands: no github_issue_comments table, no sync path,reading is a link to GitHub. apps/web/tests/server/issues.test.ts assertsthe table does not exist. A write-through POST is allowed, going straight to GitHub and returningwhat GitHub returned, exactly as PATCH /api/issues/:id already does. Thatgives an agent \"add a comment\" without creating a cache to keep in step. #26builds it; the specification is in the scope comment on #25. issue_comment is removed from HANDLED_EVENTS. It was listed there andnothing acted on it: a delivery bought a whole repository reconciliation torefresh one updated_at, on the event that fires most often in an activerepository. Wiring it to a projection would contradict the paragraph above; thehonest resolution is to stop asking GitHub to send it. §2 was describing a timer that did not exist This record says reconciliation is the baseline and webhooks the optimisation,and docs/github.md said reconciliation runs \"on demand, and on a timer\". Thetimer did not exist. grep -rn setInterval apps/web/src/server found nothingoutside tests, and the only trigger was POST /api/integrations/github/sync —so on the configuration this record calls the default, a loopback panel thatcannot receive deliveries, freshness depended entirely on somebody pressing abutton. That is a correctness gap in the exact posture the record defends, so the timerwas built rather than the sentence deleted:integrations/github/sync/schedule.ts, calling the same bounded reconcile thebutton calls, every GITHUB_SYNC_INTERVAL_MINUTES (default 15, 0 to turn itoff on a panel that does receive webhooks). No table, no route, no dependency,and a tick that arrives while the previous pass is still running is skipped. Consequences Issues #18–#22 can each be reviewed as ordinary work: the App and therepository projection, projects that own repositories, the issueprojection and sync, the board, the issue↔environment links. Local Git does not become worse. A host without a GitHub App still hasbranch, dirty state and gh-sourced pull requests. The panel gains a new class of secret and, when configured, a newclass of egress. Both are explicit, both are off by default, both arevisible to doctor. An Accepted ADR was superseded in named sentences. The next reversalof 0010 is cheaper because the remaining sentences are still true. Agents, worktrees and a future task runner are not designed here.They attach to environments.","headings":[{"id":"0018-github-access-lives-in-the-panel-through-a-github-app","text":"0018. GitHub access lives in the panel, through a GitHub App","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":12},{"id":"what-this-record-contradicts-in-earlier-adrs","text":"What this record contradicts in earlier ADRs","level":2,"line":39},{"id":"decision","text":"Decision","level":2,"line":57},{"id":"1-egress-is-opt-in-in-code-on-a-network-the-panel-already-has","text":"1. Egress is opt-in in code, on a network the panel already has","level":3,"line":69},{"id":"2-reconciliation-is-the-baseline-webhooks-are-an-optimisation","text":"2. Reconciliation is the baseline; webhooks are an optimisation","level":3,"line":91},{"id":"3-the-private-key-is-not-a-env-value-the-panel-can-write","text":"3. The private key is not a .env value the panel can write","level":3,"line":121},{"id":"4-a-projection-is-a-third-category","text":"4. A projection is a third category","level":3,"line":145},{"id":"5-source-of-truth","text":"5. Source of truth","level":3,"line":158},{"id":"6-project-environment-repository","text":"6. Project, environment, repository","level":3,"line":177},{"id":"7-degradation","text":"7. Degradation","level":3,"line":203},{"id":"8-read-only-mode-covers-writes-that-leave-the-host","text":"8. Read-only mode covers writes that leave the host","level":3,"line":221},{"id":"9-dependency-budget","text":"9. Dependency budget","level":3,"line":229},{"id":"amendment-2026-09-02-the-audit-in-25","text":"Amendment, 2026-09-02: the audit in #25","level":2,"line":257},{"id":"octokit-keep-the-purpose-built-client-and-here-is-the-measurement","text":"Octokit: keep the purpose-built client, and here is the measurement","level":3,"line":264},{"id":"graphql-and-projects-v2-amend--a-conditional-fourth-source","text":"GraphQL and Projects v2: amend — a conditional fourth source","level":3,"line":285},{"id":"comments-amend--never-projected-and-writable-through","text":"Comments: amend — never projected, and writable through","level":3,"line":301},{"id":"2-was-describing-a-timer-that-did-not-exist","text":"§2 was describing a timer that did not exist","level":3,"line":321},{"id":"consequences","text":"Consequences","level":2,"line":339}],"kind":"markdown"},{"slug":"adr/0019-compose-files-live-under-docker","title":"0019. The compose files live under docker/compose/, one directory per axis","description":"0019. The compose files live under `docker/compose/`, one directory per axis.","source":"docs/development/adr/0019-compose-files-live-under-docker.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0019-compose-files-live-under-docker","markdown":"# 0019. The compose files live under `docker/compose/`, one directory per axis\n\n**Status:** Accepted\n\n## Context\n\nThe gateway is not one Compose file. It is a base plus a set of overlays that\neach contribute only the keys they change, per\n[ADR 0003](0003-traefik-static-config-via-env.md). By the time the panel, TCP\nrouting and the Tailscale attachment existed, that had grown to **fifteen\n`compose*.yaml` files sitting in the repository root**, next to `justfile`,\n`README.md` and everything else. The root stopped being scannable, and the\nfiles gave no hint of the structure behind them: nothing in the flat listing\nsaid that exactly one `attach-*` is always selected, or that\n`compose.dashboard.yaml` and `compose.dashboard-tailscale.yaml` are two answers\nto one question and never both.\n\nThe structure was never flat. Each file is chosen by a distinct condition in\n`portta_compose_files`, and the conditions form a matrix with one axis per decision:\n\n| Axis | Files | Selected by |\n|---|---|---|\n| Base | `compose.yaml` | always |\n| Attachment | `attach/{host,tailscale}.yaml` | exactly one, always |\n| Profile | `profiles/{local,local-tls,remote,public}.yaml` | the profile, plus TLS mode |\n| Dashboard | `features/dashboard{,-tailscale}.yaml` | `PORTTA_DASHBOARD` and the attachment |\n| TCP | `features/tcp{,-tailscale}.yaml` | `PORTTA_TCP` |\n| Panel | `features/{web,web-dev,web-vpn,db}.yaml` | `PORTTA_WEB` and friends |\n\n### Why the pairs are not consolidated\n\nThe obvious reading is that `dashboard.yaml` and `dashboard-tailscale.yaml` are\nduplication waiting to be merged behind a Compose `profiles:` key. They are not.\nCompose profiles gate **whole services**, not fragments of one. What differs\nbetween the pair is which already-existing service carries the `ports:` entry:\n`traefik` owns its network namespace under the host attachment, and owns nothing\nunder the Tailscale one, where `tailscale` publishes on its behalf\n([ADR 0007](0007-tailscale-sidecar.md)). The same holds for the TCP pair. There\nis no Compose construct that expresses \"put this port on a different service\ndepending on an earlier overlay\", so the pair is the mechanism, not an accident.\n\nMerging them would mean changing behaviour. The problem to solve was layout.\n\n### Why relative paths did not have to move with the files\n\nThe overlays carry seventeen relative bind mounts (`./config/traefik/dynamic`,\n`./state/traefik/acme`, `./.env`, `./apps/web/src`,\n`./packages/core/src`) and three build contexts of `context: .` that must be the\nmonorepo root, because `apps/web/Dockerfile` copies the workspace lockfile.\n\nCompose resolves every relative path against the **project directory**, which\ndefaults to the directory of the first `-f` file — not the directory of the file\nthe path is written in. Moving the files would therefore have re-anchored all\ntwenty paths at `docker/compose/`. `--project-directory` overrides that default,\nand it does not touch the project name, which `docker/compose/compose.yaml`\nstill declares explicitly as `name: ${PORTTA_PROJECT_NAME:-portta}`.\n\n## Decision\n\nThe gateway's compose files live under `docker/compose/`, in one directory per axis:\n\n```\ndocker/\n├── compose/\n ├── compose.yaml\n ├── attach/ host.yaml, tailscale.yaml\n ├── profiles/ local.yaml, local-tls.yaml, remote.yaml, public.yaml\n └── features/ dashboard.yaml, dashboard-tailscale.yaml, tcp.yaml,\n tcp-tailscale.yaml, web.yaml, web-dev.yaml, web-vpn.yaml, db.yaml\n└── examples/ self-contained demonstration stacks\n```\n\nThe file names drop the `compose.` prefix and the axis name, both of which the\ndirectory now carries: `compose.attach-host.yaml` is `attach/host.yaml`.\n\nEvery invocation passes `--project-directory <repository root>`, so paths\ninside the files resolve from the repository root. The canonical argument\nbuilder is `composeArguments` in `packages/cli/src/context.ts`.\n\nThe runnable Compose examples live separately under `docker/examples/`. Their\nsupporting files move with them so relative bind mounts continue to resolve\ninside each self-contained demo directory.\n\n## Consequences\n\nThe root lists one `docker/` entry instead of fifteen files and the former\nroot-level examples tree. The nested directory names state the matrix that was\npreviously only readable in `portta_compose_files`.\n\n`composeFiles` in `packages/core/src/config.ts` is the single selection\nimplementation used by the CLI.\n\n`packages/cli/src/context.ts` locates a gateway checkout by looking for a\nCompose file under `docker/compose/`. A published CLI outlives the checkout it\nis pointed at in both directions, so earlier layouts remain recognised during\nroot discovery.\n\nAnyone who was invoking `docker compose -f compose.yaml -f compose.local.yaml`\nby hand needs the new paths. That was never the supported interface — the base\nfile says so in its header, and the CLI is the stable operational contract — but\nit is the one thing this change breaks. `just` and `./bin/portta` are\nunaffected, and so are consumer projects: `compose.portta.yaml` lives in\nthe adopted project, not here.\n","text":"0019. The compose files live under docker/compose/, one directory per axis Status: Accepted Context The gateway is not one Compose file. It is a base plus a set of overlays thateach contribute only the keys they change, perADR 0003. By the time the panel, TCProuting and the Tailscale attachment existed, that had grown to fifteencompose*.yaml files sitting in the repository root, next to justfile,README.md and everything else. The root stopped being scannable, and thefiles gave no hint of the structure behind them: nothing in the flat listingsaid that exactly one attach-* is always selected, or thatcompose.dashboard.yaml and compose.dashboard-tailscale.yaml are two answersto one question and never both. The structure was never flat. Each file is chosen by a distinct condition inportta_compose_files, and the conditions form a matrix with one axis per decision: Axis Files Selected by Base compose.yaml always Attachment attach/{host,tailscale}.yaml exactly one, always Profile profiles/{local,local-tls,remote,public}.yaml the profile, plus TLS mode Dashboard features/dashboard{,-tailscale}.yaml PORTTA_DASHBOARD and the attachment TCP features/tcp{,-tailscale}.yaml PORTTA_TCP Panel features/{web,web-dev,web-vpn,db}.yaml PORTTA_WEB and friends Why the pairs are not consolidated The obvious reading is that dashboard.yaml and dashboard-tailscale.yaml areduplication waiting to be merged behind a Compose profiles: key. They are not.Compose profiles gate whole services, not fragments of one. What differsbetween the pair is which already-existing service carries the ports: entry:traefik owns its network namespace under the host attachment, and owns nothingunder the Tailscale one, where tailscale publishes on its behalf(ADR 0007). The same holds for the TCP pair. Thereis no Compose construct that expresses \"put this port on a different servicedepending on an earlier overlay\", so the pair is the mechanism, not an accident. Merging them would mean changing behaviour. The problem to solve was layout. Why relative paths did not have to move with the files The overlays carry seventeen relative bind mounts (./config/traefik/dynamic,./state/traefik/acme, ./.env, ./apps/web/src,./packages/core/src) and three build contexts of context: . that must be themonorepo root, because apps/web/Dockerfile copies the workspace lockfile. Compose resolves every relative path against the project directory, whichdefaults to the directory of the first -f file — not the directory of the filethe path is written in. Moving the files would therefore have re-anchored alltwenty paths at docker/compose/. --project-directory overrides that default,and it does not touch the project name, which docker/compose/compose.yamlstill declares explicitly as name: ${PORTTA_PROJECT_NAME:-portta}. Decision The gateway's compose files live under docker/compose/, in one directory per axis: The file names drop the compose. prefix and the axis name, both of which thedirectory now carries: compose.attach-host.yaml is attach/host.yaml. Every invocation passes --project-directory <repository root>, so pathsinside the files resolve from the repository root. The canonical argumentbuilder is composeArguments in packages/cli/src/context.ts. The runnable Compose examples live separately under docker/examples/. Theirsupporting files move with them so relative bind mounts continue to resolveinside each self-contained demo directory. Consequences The root lists one docker/ entry instead of fifteen files and the formerroot-level examples tree. The nested directory names state the matrix that waspreviously only readable in portta_compose_files. composeFiles in packages/core/src/config.ts is the single selectionimplementation used by the CLI. packages/cli/src/context.ts locates a gateway checkout by looking for aCompose file under docker/compose/. A published CLI outlives the checkout itis pointed at in both directions, so earlier layouts remain recognised duringroot discovery. Anyone who was invoking docker compose -f compose.yaml -f compose.local.yamlby hand needs the new paths. That was never the supported interface — the basefile says so in its header, and the CLI is the stable operational contract — butit is the one thing this change breaks. just and ./bin/portta areunaffected, and so are consumer projects: compose.portta.yaml lives inthe adopted project, not here.","headings":[{"id":"0019-the-compose-files-live-under-dockercompose-one-directory-per-axis","text":"0019. The compose files live under docker/compose/, one directory per axis","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"why-the-pairs-are-not-consolidated","text":"Why the pairs are not consolidated","level":3,"line":29},{"id":"why-relative-paths-did-not-have-to-move-with-the-files","text":"Why relative paths did not have to move with the files","level":3,"line":43},{"id":"decision","text":"Decision","level":2,"line":57},{"id":"consequences","text":"Consequences","level":2,"line":83}],"kind":"markdown"},{"slug":"adr/0020-installer-and-portta-home","title":"0020. An installation is one runtime directory","description":"0020. Installing Portta means one directory and published images, not a checkout.","source":"docs/development/adr/0020-installer-and-portta-home.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0020-installer-and-portta-home","markdown":"# 0020. An installation is one runtime directory\n\n**Status:** Accepted\n\n`portta setup` installs the runtime assets bundled in the npm package into one\nchosen directory. That directory owns `.env`, Compose files, configuration,\nstate and the installed launcher. It is not a Git checkout.\n\nSetup preserves existing configuration and state, refuses unrelated non-empty\ndirectories, creates only gateway-owned paths, prepares the shared networks,\npulls pinned images and starts the selected profile. Repeating setup is\nidempotent. Development uses a checkout and local build overlays explicitly.\n","text":"0020. An installation is one runtime directory Status: Accepted portta setup installs the runtime assets bundled in the npm package into onechosen directory. That directory owns .env, Compose files, configuration,state and the installed launcher. It is not a Git checkout. Setup preserves existing configuration and state, refuses unrelated non-emptydirectories, creates only gateway-owned paths, prepares the shared networks,pulls pinned images and starts the selected profile. Repeating setup isidempotent. Development uses a checkout and local build overlays explicitly.","headings":[{"id":"0020-an-installation-is-one-runtime-directory","text":"0020. An installation is one runtime directory","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0021-panel-access-modes","title":"0021. Panel access is an explicit decision","description":"0021. Panel access is its own decision, and a public panel gets its own entrypoint.","source":"docs/development/adr/0021-panel-access-modes.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0021-panel-access-modes","markdown":"# 0021. Panel access is an explicit decision\n\n**Status:** Accepted\n\nThe panel access modes are `local`, `tailscale`, `vpn`, `domain` and `public`.\n`local` binds loopback. Every other mode requires panel authentication and is\nvalidated against the selected gateway profile. Routed modes default to\nread-only unless the operator deliberately enables writes.\n\nThe browser origin is recorded in `PORTTA_PANEL_URL`; additional trusted\norigins are explicit. The Traefik dashboard is separate from the panel and\nalways remains on loopback.\n","text":"0021. Panel access is an explicit decision Status: Accepted The panel access modes are local, tailscale, vpn, domain and public.local binds loopback. Every other mode requires panel authentication and isvalidated against the selected gateway profile. Routed modes default toread-only unless the operator deliberately enables writes. The browser origin is recorded in PORTTA_PANEL_URL; additional trustedorigins are explicit. The Traefik dashboard is separate from the panel andalways remains on loopback.","headings":[{"id":"0021-panel-access-is-an-explicit-decision","text":"0021. Panel access is an explicit decision","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0022-project-domain-modes","title":"0022. The base domain is a mode, and a host with no domain gets one from its address","description":"0022. The base domain is a mode, and a host with no domain gets one from its address.","source":"docs/development/adr/0022-project-domain-modes.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0022-project-domain-modes","markdown":"# 0022. The base domain is a mode, and a host with no domain gets one from its address\n\n**Status:** Accepted\n\n## Context\n\nEvery project hostname Portta serves is derived, never stored:\n`<compose-project>-<service>.<base>`. Traefik bakes the base into its default\nrule at startup ([ADR 0005](0005-hostname-convention.md)), and three surfaces\nre-derive the same name for display — `portta_discover_http` in the shell,\n`routesFor` in the shared core, and `urlsFor` in the panel.\n\nThe base came from one variable with one default:\n\n```\nPORTTA_DOMAIN=localhost\n```\n\noverridden only by `PRIVATE_DOMAIN` on `remote-private` and `PUBLIC_DOMAIN` on\n`remote-public`. That is correct on a workstation. `.localhost` resolves to\nloopback with no DNS server, no `/etc/hosts` line and no configuration at all,\nwhich is most of why the local profile is pleasant to use.\n\nIt is wrong on a VPS, and [ADR 0020](0020-installer-and-portta-home.md) made\nthat the common case. The installer pins the gateway to the `local` profile on\npurpose — so that publishing the panel publishes no application — and the\n`local` profile pins the domain to `localhost`. A host installed from\n`curl … | bash`, with the panel deliberately reachable over the internet,\ntherefore advertised URLs like:\n\n```\nteste.localhost\n```\n\nto somebody reading the panel from another country. The name resolves to *their*\nloopback. Nothing they can do makes it open.\n\nThe only escape was `remote-public`, which requires a domain you own and also\nbinds Traefik to every interface. So \"I want a URL I can click\" was coupled to\n\"expose every routed application to the internet\" and \"go buy a domain first\" —\ntwo decisions the operator had not asked to make, and the first of which\n[ADR 0021](0021-panel-access-modes.md) had just finished decoupling for the\npanel and left coupled for applications.\n\n### What the free wildcard DNS services do\n\n`sslip.io` and `nip.io` answer for any name embedding an IPv4 address, with no\nrecord to create, no account and no registration:\n\n```\n$ dig +short demo-app-web.2-28-24-129.sslip.io\n2.28.24.129\n```\n\nBoth accept a dotted form (`1.2.3.4.sslip.io`) and a dashed one\n(`1-2-3-4.sslip.io`), and both resolve arbitrary prefixes above it. Verified for\nboth services before adopting either.\n\n## Decision\n\n**The base is a mode, not a value.**\n\n| `PORTTA_DOMAIN_MODE` | Base | For |\n|---|---|---|\n| `local` | `localhost` | a machine you are sitting at |\n| `auto` | `<ip-with-dashes>.sslip.io` | a host with a public address and no domain |\n| `custom` | `PORTTA_DOMAIN` | a wildcard you own |\n\n`local` remains the default, so an existing installation resolves exactly as it\ndid. Supporting values are `PORTTA_PUBLIC_IP` (detected once, written to\n`.env`) and `PORTTA_AUTO_DOMAIN_PROVIDER` (`sslip.io` by default, `nip.io` the\nother accepted value).\n\n### The dashed form, deliberately\n\n`1-2-3-4.sslip.io` keeps the address inside a single DNS label. That leaves\n`<project>-<service>` as its own label, so the whole name is one level below the\nbase — which is what makes `*.1-2-3-4.sslip.io` a wildcard a single certificate\ncould cover. The dotted form spreads the address over four labels and puts\nproject hostnames two levels down, where a wildcard does not reach.\n\n### Resolution happens once\n\n`resolveDomain` in `packages/core/src/domain.ts` computes the base used by the\nCLI and every consumer that derives a route.\n\nEverything downstream keeps reading one resolved `PORTTA_DOMAIN`. That is what\nmakes changing the mode free: hostnames are derived and never persisted, so a\nmode change re-labels every project at once, with no project touched, nothing\nto migrate and no route to rewrite by hand.\n\n### A failed mode falls back and says why\n\nA mode that cannot be honoured — `auto` with no address, `custom` with no\ndomain — resolves to `localhost` and records the reason in\n`PORTTA_DOMAIN_PROBLEM`, which `status`, `doctor` and the panel report. A\ngateway that refuses to start over an unreachable hostname is worse than the\nhostname.\n\n### `remote-public` accepts a derived base\n\nGoing public no longer requires owning a domain: when `PUBLIC_DOMAIN` is unset\nand the mode yields something other than `localhost`, that becomes the public\nbase. An explicit `PUBLIC_DOMAIN` still wins, and `local` still cannot satisfy\nthe profile — publishing `*.localhost` to the internet would serve nobody.\n\n### The installer chooses from an answer it already has\n\nPanel access already asks the question this needs: anything but `local` means\n\"I reach this machine from somewhere else\". So a host whose panel is public or\non the tailnet defaults to `auto`, and a machine whose panel is loopback-only\nkeeps `local`. `--domain-mode` overrides, and an update keeps whatever is\nconfigured.\n\n### A name is not an exposure\n\nThis decision governs **what a service is called**, and nothing else. Which\ninterface Traefik answers on is `PORTTA_BIND_ADDRESS` and `PUBLIC_ENABLED`;\nwhether a service is routed at all is its own `traefik.enable` label. An auto\ndomain on the default `local` profile produces names that resolve to the host\nand reach a Traefik that is listening on loopback only — correct, and useless\nuntil somebody deliberately enables public access.\n\nSaying that plainly is part of the decision. `doctor` and the panel both report\nthe combination rather than silently doing one thing when the operator asked\nfor the other:\n\n```\n[warn] project hostnames *.2-28-24-129.sslip.io points here, but Traefik listens on 127.0.0.1 only\n -> portta public enable exposes the HTTP services that opted in\n```\n\n## Consequences\n\nA VPS installed with `curl … | bash` now hands out hostnames that resolve from\nanywhere, and the panel shows the operator exactly what a project will be\ncalled before any project exists.\n\n**`doctor` checks ownership.** `dns.wildcard` compares the resolved address\nagainst this host's and fails on a mismatch, preventing generated URLs from\nloading another machine.\n\n**Automatic HTTPS is not part of this.** Let's Encrypt can issue for a single\n`web.1-2-3-4.sslip.io` over HTTP-01, but Portta's ACME configuration is DNS-01\n(the only challenge that can issue a wildcard), and neither service offers an\nAPI for DNS-01. A wildcard certificate for `*.1-2-3-4.sslip.io` is therefore not\nobtainable, and per-hostname HTTP-01 would need port 80 open to the internet —\nwhich is the exposure decision this ADR deliberately keeps separate. Auto\ndomains serve HTTP; `docs/dns-and-tls.md` says so rather than implying\notherwise.\n\n**Three variables where there was one.** `PORTTA_DOMAIN` still exists and still\nmeans what it meant, but it is now read only in `custom` mode, and the other\nmodes overwrite it. Editing it without switching the mode writes a value\nnothing reads, so `portta config set gateway.domain` says so.\n","text":"0022. The base domain is a mode, and a host with no domain gets one from its address Status: Accepted Context Every project hostname Portta serves is derived, never stored:<compose-project>-<service>.<base>. Traefik bakes the base into its defaultrule at startup (ADR 0005), and three surfacesre-derive the same name for display — portta_discover_http in the shell,routesFor in the shared core, and urlsFor in the panel. The base came from one variable with one default: overridden only by PRIVATE_DOMAIN on remote-private and PUBLIC_DOMAIN onremote-public. That is correct on a workstation. .localhost resolves toloopback with no DNS server, no /etc/hosts line and no configuration at all,which is most of why the local profile is pleasant to use. It is wrong on a VPS, and ADR 0020 madethat the common case. The installer pins the gateway to the local profile onpurpose — so that publishing the panel publishes no application — and thelocal profile pins the domain to localhost. A host installed fromcurl … | bash, with the panel deliberately reachable over the internet,therefore advertised URLs like: to somebody reading the panel from another country. The name resolves to theirloopback. Nothing they can do makes it open. The only escape was remote-public, which requires a domain you own and alsobinds Traefik to every interface. So \"I want a URL I can click\" was coupled to\"expose every routed application to the internet\" and \"go buy a domain first\" —two decisions the operator had not asked to make, and the first of whichADR 0021 had just finished decoupling for thepanel and left coupled for applications. What the free wildcard DNS services do sslip.io and nip.io answer for any name embedding an IPv4 address, with norecord to create, no account and no registration: Both accept a dotted form (1.2.3.4.sslip.io) and a dashed one(1-2-3-4.sslip.io), and both resolve arbitrary prefixes above it. Verified forboth services before adopting either. Decision The base is a mode, not a value. PORTTA_DOMAIN_MODE Base For local localhost a machine you are sitting at auto <ip-with-dashes>.sslip.io a host with a public address and no domain custom PORTTA_DOMAIN a wildcard you own local remains the default, so an existing installation resolves exactly as itdid. Supporting values are PORTTA_PUBLIC_IP (detected once, written to.env) and PORTTA_AUTO_DOMAIN_PROVIDER (sslip.io by default, nip.io theother accepted value). The dashed form, deliberately 1-2-3-4.sslip.io keeps the address inside a single DNS label. That leaves<project>-<service> as its own label, so the whole name is one level below thebase — which is what makes *.1-2-3-4.sslip.io a wildcard a single certificatecould cover. The dotted form spreads the address over four labels and putsproject hostnames two levels down, where a wildcard does not reach. Resolution happens once resolveDomain in packages/core/src/domain.ts computes the base used by theCLI and every consumer that derives a route. Everything downstream keeps reading one resolved PORTTA_DOMAIN. That is whatmakes changing the mode free: hostnames are derived and never persisted, so amode change re-labels every project at once, with no project touched, nothingto migrate and no route to rewrite by hand. A failed mode falls back and says why A mode that cannot be honoured — auto with no address, custom with nodomain — resolves to localhost and records the reason inPORTTA_DOMAIN_PROBLEM, which status, doctor and the panel report. Agateway that refuses to start over an unreachable hostname is worse than thehostname. remote-public accepts a derived base Going public no longer requires owning a domain: when PUBLIC_DOMAIN is unsetand the mode yields something other than localhost, that becomes the publicbase. An explicit PUBLIC_DOMAIN still wins, and local still cannot satisfythe profile — publishing *.localhost to the internet would serve nobody. The installer chooses from an answer it already has Panel access already asks the question this needs: anything but local means\"I reach this machine from somewhere else\". So a host whose panel is public oron the tailnet defaults to auto, and a machine whose panel is loopback-onlykeeps local. --domain-mode overrides, and an update keeps whatever isconfigured. A name is not an exposure This decision governs what a service is called, and nothing else. Whichinterface Traefik answers on is PORTTA_BIND_ADDRESS and PUBLIC_ENABLED;whether a service is routed at all is its own traefik.enable label. An autodomain on the default local profile produces names that resolve to the hostand reach a Traefik that is listening on loopback only — correct, and uselessuntil somebody deliberately enables public access. Saying that plainly is part of the decision. doctor and the panel both reportthe combination rather than silently doing one thing when the operator askedfor the other: Consequences A VPS installed with curl … | bash now hands out hostnames that resolve fromanywhere, and the panel shows the operator exactly what a project will becalled before any project exists. doctor checks ownership. dns.wildcard compares the resolved addressagainst this host's and fails on a mismatch, preventing generated URLs fromloading another machine. Automatic HTTPS is not part of this. Let's Encrypt can issue for a singleweb.1-2-3-4.sslip.io over HTTP-01, but Portta's ACME configuration is DNS-01(the only challenge that can issue a wildcard), and neither service offers anAPI for DNS-01. A wildcard certificate for *.1-2-3-4.sslip.io is therefore notobtainable, and per-hostname HTTP-01 would need port 80 open to the internet —which is the exposure decision this ADR deliberately keeps separate. Autodomains serve HTTP; docs/dns-and-tls.md says so rather than implyingotherwise. Three variables where there was one. PORTTA_DOMAIN still exists and stillmeans what it meant, but it is now read only in custom mode, and the othermodes overwrite it. Editing it without switching the mode writes a valuenothing reads, so portta config set gateway.domain says so.","headings":[{"id":"0022-the-base-domain-is-a-mode-and-a-host-with-no-domain-gets-one-from-its-address","text":"0022. The base domain is a mode, and a host with no domain gets one from its address","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"what-the-free-wildcard-dns-services-do","text":"What the free wildcard DNS services do","level":3,"line":44},{"id":"decision","text":"Decision","level":2,"line":58},{"id":"the-dashed-form-deliberately","text":"The dashed form, deliberately","level":3,"line":73},{"id":"resolution-happens-once","text":"Resolution happens once","level":3,"line":81},{"id":"a-failed-mode-falls-back-and-says-why","text":"A failed mode falls back and says why","level":3,"line":91},{"id":"remote-public-accepts-a-derived-base","text":"remote-public accepts a derived base","level":3,"line":99},{"id":"the-installer-chooses-from-an-answer-it-already-has","text":"The installer chooses from an answer it already has","level":3,"line":106},{"id":"a-name-is-not-an-exposure","text":"A name is not an exposure","level":3,"line":114},{"id":"consequences","text":"Consequences","level":2,"line":132}],"kind":"markdown"},{"slug":"adr/0023-flat-hostname-labels","title":"0023. A service's whole name lives in one DNS label","description":"0023. A service's whole name lives in one DNS label.","source":"docs/development/adr/0023-flat-hostname-labels.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0023-flat-hostname-labels","markdown":"# 0023. A service's whole name lives in one DNS label\n\n**Status:** Accepted, amends [0005](0005-hostname-convention.md)\n\n## Context\n\n[ADR 0005](0005-hostname-convention.md) derives every project hostname from the\nlabels Compose already injects, as `<project>-<service>.<base>`. It has served\nwell and nothing about it is wrong. Two things it did not have to answer have\nnow come up.\n\n**A name may need a third component.** A branch, a pull request, a preview\nenvironment — anything that distinguishes one run of the same service from\nanother. `<project>-<service>` has nowhere to put it.\n\n**A single `-` cannot be read back.** `slug` replaces every run of\nnon-alphanumerics with one `-`, so `storefront-web` could be project\n`storefront` service `web`, or project `storefront-web` with no service at all.\nThe gateway never needed to parse a hostname, so this cost nothing. Anything\nthat reconciles routes, or that maps a hostname back to a container, does.\n\nThe obvious answer — more levels, `web.storefront.example.com` — was measured\nbefore being rejected.\n\n### What a second level actually costs\n\nCloudflare's Universal SSL covers **the apex and first-level subdomains only**.\n`web.storefront.example.com` is a second level and is not covered. Serving it\nover HTTPS requires Advanced Certificate Manager, a **paid add-on on every\nplan**, or Total TLS, which is part of the same add-on. Cloudflare's own tunnel\ntroubleshooting says so directly: a multi-level subdomain shows\n`This site can't provide a secure connection` until an advanced certificate is\nordered.\n\nThe same limit applies with no Cloudflare in sight. A wildcard certificate\ncovers one level, by the standard, so `*.1-2-3-4.sslip.io` cannot cover\n`web.demo.1-2-3-4.sslip.io` either. Multi-level naming would mean a certificate\nper project, on a host that by design has no DNS API to answer a DNS-01\nchallenge with.\n\nSo the choice is not \"flat or nested\". It is \"flat, or ask every operator to buy\na certificate add-on before they can name a second project\".\n\n## Decision\n\n**Every component of a name goes in one label, separated by `--`.**\n\n```\nweb--storefront.example.com\nweb--storefront--pr-42.example.com\napi--shop--preview-7.example.com\n```\n\n`--` works as a separator precisely because `slug` collapses runs of `-`: no\ncomponent can ever contain two in a row, so the split is unambiguous in both\ndirections. `parseHostLabel` reads that form back into its parts and returns\nnull for forms that do not carry unambiguous boundaries.\n\nLength is bounded by the DNS, not by us: a label may not exceed 63 octets and a\nwhole name 253. `fitLabel` trims an over-long label and appends a short digest\n**of the original**, so two long branch names cannot truncate onto each other\nand silently route to whichever container Traefik matched first.\n\n### The project-first style is the default\n\n`PORTTA_HOSTNAME_STYLE` takes `project-service` (the default) and\n`service--project`. Changing the default would move every URL on every existing\ninstallation — every bookmark, every DNS expectation, every hardcoded host in\nsomebody's `.env` — to fix a problem those installations do not have. A fresh\ninstall can pick the new style; an existing one keeps working and can opt in\nwhen it wants the third component.\n\nBoth styles are produced from one place. `defaultRuleTemplate` generates the\nTraefik `defaultRule` that bakes the choice in at container start, and\n`hostLabel` derives the same name for display, so the panel cannot print one\nname while the gateway serves another.\n\n## Consequences\n\n**One wildcard covers everything.** `*.example.com` — the record the operator\nalready has, on the certificate Cloudflare already issues for free — covers\nevery project this gateway will ever route, including ones that do not exist\nyet. That is what makes both the automatic domains and the Cloudflare Tunnel\nin [ADR 0025](0025-cloudflare-tunnel.md) work without a per-service record.\n\n**Names get longer and less pretty.** `web--storefront--feature-auth-login` is\na mouthful next to `web.login.storefront.example.com`. That is the trade, and it\nbuys a free certificate and a name that can be parsed.\n\n**A very long branch produces a digest.** `feature/…` names that exceed the\nlabel limit end in six characters nobody chose. The alternative was two branches\nsharing a hostname, which is worse in a way that is much harder to notice.\n\n**Two styles to keep in step.** Both are generated by the same two functions and\ncovered by the same tests; a third would not be, and there should not be one.\n","text":"0023. A service's whole name lives in one DNS label Status: Accepted, amends 0005 Context ADR 0005 derives every project hostname from thelabels Compose already injects, as <project>-<service>.<base>. It has servedwell and nothing about it is wrong. Two things it did not have to answer havenow come up. A name may need a third component. A branch, a pull request, a previewenvironment — anything that distinguishes one run of the same service fromanother. <project>-<service> has nowhere to put it. A single - cannot be read back. slug replaces every run ofnon-alphanumerics with one -, so storefront-web could be projectstorefront service web, or project storefront-web with no service at all.The gateway never needed to parse a hostname, so this cost nothing. Anythingthat reconciles routes, or that maps a hostname back to a container, does. The obvious answer — more levels, web.storefront.example.com — was measuredbefore being rejected. What a second level actually costs Cloudflare's Universal SSL covers the apex and first-level subdomains only.web.storefront.example.com is a second level and is not covered. Serving itover HTTPS requires Advanced Certificate Manager, a paid add-on on everyplan, or Total TLS, which is part of the same add-on. Cloudflare's own tunneltroubleshooting says so directly: a multi-level subdomain showsThis site can't provide a secure connection until an advanced certificate isordered. The same limit applies with no Cloudflare in sight. A wildcard certificatecovers one level, by the standard, so *.1-2-3-4.sslip.io cannot coverweb.demo.1-2-3-4.sslip.io either. Multi-level naming would mean a certificateper project, on a host that by design has no DNS API to answer a DNS-01challenge with. So the choice is not \"flat or nested\". It is \"flat, or ask every operator to buya certificate add-on before they can name a second project\". Decision Every component of a name goes in one label, separated by --. -- works as a separator precisely because slug collapses runs of -: nocomponent can ever contain two in a row, so the split is unambiguous in bothdirections. parseHostLabel reads that form back into its parts and returnsnull for forms that do not carry unambiguous boundaries. Length is bounded by the DNS, not by us: a label may not exceed 63 octets and awhole name 253. fitLabel trims an over-long label and appends a short digestof the original, so two long branch names cannot truncate onto each otherand silently route to whichever container Traefik matched first. The project-first style is the default PORTTA_HOSTNAME_STYLE takes project-service (the default) andservice--project. Changing the default would move every URL on every existinginstallation — every bookmark, every DNS expectation, every hardcoded host insomebody's .env — to fix a problem those installations do not have. A freshinstall can pick the new style; an existing one keeps working and can opt inwhen it wants the third component. Both styles are produced from one place. defaultRuleTemplate generates theTraefik defaultRule that bakes the choice in at container start, andhostLabel derives the same name for display, so the panel cannot print onename while the gateway serves another. Consequences One wildcard covers everything. *.example.com — the record the operatoralready has, on the certificate Cloudflare already issues for free — coversevery project this gateway will ever route, including ones that do not existyet. That is what makes both the automatic domains and the Cloudflare Tunnelin ADR 0025 work without a per-service record. Names get longer and less pretty. web--storefront--feature-auth-login isa mouthful next to web.login.storefront.example.com. That is the trade, and itbuys a free certificate and a name that can be parsed. A very long branch produces a digest. feature/… names that exceed thelabel limit end in six characters nobody chose. The alternative was two branchessharing a hostname, which is worse in a way that is much harder to notice. Two styles to keep in step. Both are generated by the same two functions andcovered by the same tests; a third would not be, and there should not be one.","headings":[{"id":"0023-a-services-whole-name-lives-in-one-dns-label","text":"0023. A service's whole name lives in one DNS label","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"what-a-second-level-actually-costs","text":"What a second level actually costs","level":3,"line":24},{"id":"decision","text":"Decision","level":2,"line":43},{"id":"the-project-first-style-is-the-default","text":"The project-first style is the default","level":3,"line":63},{"id":"consequences","text":"Consequences","level":2,"line":77}],"kind":"markdown"},{"slug":"adr/0024-capabilities-providers-endpoints","title":"0024. A service has endpoints, not an access mode","description":"0024. A service has endpoints, not an access mode.","source":"docs/development/adr/0024-capabilities-providers-endpoints.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0024-capabilities-providers-endpoints","markdown":"# 0024. A service has endpoints, not an access mode\n\n**Status:** Accepted\n\n## Context\n\nPortta answered one question about reachability — *what is the domain of this\nproject?* — and derived every URL from the answer.\n[ADR 0022](0022-project-domain-modes.md) improved the answer by making the base\na mode rather than a value, but it did not change the shape of the question, and\nthe shape is the problem.\n\nA machine does not have *a* way to be reached. The same host can be, all at\nonce: a loopback address, a LAN address, a tailnet address, a public address, a\nname under a wildcard the operator owns, and a hostname served through an\noutbound tunnel. Which of those exist is a property of the **host**. Which of\nthem a given service uses is a decision about that **service**. Collapsing both\ninto one project-wide domain forces one answer where several are correct, and\nproduces the failure that started this: a panel read over the internet\nconfidently advertising `demo-web.localhost`.\n\nThree distinct things had been fused into that single value:\n\n| | |\n|---|---|\n| **what a service is called** | a hostname |\n| **where the gateway listens** | `PORTTA_BIND_ADDRESS`, `PUBLIC_ENABLED` |\n| **who may reach it** | nothing modelled this at all |\n\nThe third was simply missing. `portta share` had the beginnings of it — three\nstates, per service, with an expiry — but only over whatever network the gateway\nalready sat on, and only as an extra hostname.\n\n## Decision\n\n**Three layers, each answering exactly one question.**\n\n```\nCapabilities what this host can do detected\n └── Providers how an endpoint can be made offered when the capability allows\n └── Endpoints the concrete URLs created when the operator says so\n```\n\n### Capabilities are detected, never configured\n\n`localhost`, `lan`, `tailscale`, `tailscale-dns`, `tailscale-https`,\n`tailscale-funnel`, `public-ipv4`, `auto-domain`, `custom-domain`,\n`cloudflare-tunnel`, `cloudflare-access`, `https`.\n\nEach carries one of six states, because yes/no loses the distinction that\nmatters most — between what this host *cannot* do and what it *could*, as soon\nas somebody decides:\n\n```\nunavailable configurable configured available active error\n```\n\n`error` is deliberately a state and not a hidden failure: a tunnel that is set\nup and not connected is the single most useful thing to say, and the version of\nthis model that dropped it produced an empty panel with no explanation. A test\npins that behaviour.\n\nDetection lives in `packages/cli/src/detect.ts`, over the host probes in\n`packages/cli/src/host.ts`. The verdicts — which facts add up to which state —\nlive in `packages/core/src/capabilities.ts`. The split is what lets the\nverdicts be tested without a host and keeps `packages/core` free of process\nexecution; `packages/cli/src/detect.test.ts` asserts that the detected shape is\nexactly the shape the verdicts declare, and drives the verdicts from it.\n\n### A capability is not an exposure\n\nThis is the load-bearing rule. Detecting that a host has a public address, a\ntailnet, or a working tunnel publishes **nothing**. Every optional provider is\noff until it is turned on, one service at a time. The tests state it as plainly\nas the prose does: a host with every capability available and no exposure\nenabled produces exactly two endpoints, `internal` and `local`.\n\n### A service has several endpoints, at once\n\n```\nService: web\n\n internal web:3000 other containers\n private http://web--shop.100-87-243-7.sslip.io the tailnet\n public https://web--shop.portta.app the internet\n```\n\nEach endpoint carries the one sentence a panel must never get wrong — `scope`,\nwith six values from `internal` to `public` — and two booleans that are not the\nsame question:\n\n- **`usable`** — does this URL work right now?\n- **`shareable`** — can it be sent to somebody else?\n\nA `local` endpoint can be perfectly usable and never shareable. A public\nhostname can resolve correctly and be useless, which is exactly the case that\nmotivated the field: `domainReachesBind` checks that the name resolves to an\naddress Traefik actually listens on, so a wildcard pointing at a host whose\nTraefik is on loopback is reported as broken rather than offered as a URL.\n\n### Datastores get one endpoint and no choices\n\nA service whose kind is not `http` returns after the internal endpoint,\nwhatever providers are enabled. Postgres and Redis are reached over the access\nnetwork or an SSH bridge; giving them an HTTP hostname would be offering\nsomething that cannot work. This matches the refusal `portta share` already\nmakes.\n\n**Amended 2026-09-02.** A datastore whose protocol can be told apart by\nhostname (`isHostnameRoutable` / `tcpEntrypoint`) gets one endpoint per\nprovider that can carry it — `local`, `lan`, `tailscale`, `auto-domain`,\n`custom-domain` — as `host:port`, never as an HTTP URL. A kind whose\n`TcpRouting` is `unsupported` or `unevaluated` still gets exactly the\ninternal endpoint. A live loopback bridge is a `bridge` endpoint with\nscope `local`. The rule this section was protecting stays: a datastore is\nnever given an HTTP hostname, and discovering an address never creates a\nroute.\n\n## Consequences\n\n**`portta share` becomes a special case of this, not a parallel system.** A\nshare is an endpoint with an expiry and a credential. It keeps its own file and\nits own commands; the model now has a place for it to belong.\n\n**The panel can finally show the truth.** \"This name resolves here, and Traefik\nis not listening on it\" is a sentence the old model could not form, so it\nprinted a URL instead.\n\n**More state to detect, some of it over the network.** Capability detection runs\nprobes — `tailscale status`, a Docker inspect, a log grep. It is read-only by\nconstruction and never authenticates anything, but it is not free, so it is\ngathered once per request rather than per service.\n\n**Exposures are per service.** A project with six services has six independent\ndecisions available. Everything defaults to off.\n","text":"0024. A service has endpoints, not an access mode Status: Accepted Context Portta answered one question about reachability — what is the domain of thisproject? — and derived every URL from the answer.ADR 0022 improved the answer by making the basea mode rather than a value, but it did not change the shape of the question, andthe shape is the problem. A machine does not have a way to be reached. The same host can be, all atonce: a loopback address, a LAN address, a tailnet address, a public address, aname under a wildcard the operator owns, and a hostname served through anoutbound tunnel. Which of those exist is a property of the host. Which ofthem a given service uses is a decision about that service. Collapsing bothinto one project-wide domain forces one answer where several are correct, andproduces the failure that started this: a panel read over the internetconfidently advertising demo-web.localhost. Three distinct things had been fused into that single value: what a service is called a hostname where the gateway listens PORTTA_BIND_ADDRESS, PUBLIC_ENABLED who may reach it nothing modelled this at all The third was simply missing. portta share had the beginnings of it — threestates, per service, with an expiry — but only over whatever network the gatewayalready sat on, and only as an extra hostname. Decision Three layers, each answering exactly one question. Capabilities are detected, never configured localhost, lan, tailscale, tailscale-dns, tailscale-https,tailscale-funnel, public-ipv4, auto-domain, custom-domain,cloudflare-tunnel, cloudflare-access, https. Each carries one of six states, because yes/no loses the distinction thatmatters most — between what this host cannot do and what it could, as soonas somebody decides: error is deliberately a state and not a hidden failure: a tunnel that is setup and not connected is the single most useful thing to say, and the version ofthis model that dropped it produced an empty panel with no explanation. A testpins that behaviour. Detection lives in packages/cli/src/detect.ts, over the host probes inpackages/cli/src/host.ts. The verdicts — which facts add up to which state —live in packages/core/src/capabilities.ts. The split is what lets theverdicts be tested without a host and keeps packages/core free of processexecution; packages/cli/src/detect.test.ts asserts that the detected shape isexactly the shape the verdicts declare, and drives the verdicts from it. A capability is not an exposure This is the load-bearing rule. Detecting that a host has a public address, atailnet, or a working tunnel publishes nothing. Every optional provider isoff until it is turned on, one service at a time. The tests state it as plainlyas the prose does: a host with every capability available and no exposureenabled produces exactly two endpoints, internal and local. A service has several endpoints, at once Each endpoint carries the one sentence a panel must never get wrong — scope,with six values from internal to public — and two booleans that are not thesame question: usable — does this URL work right now? shareable — can it be sent to somebody else? A local endpoint can be perfectly usable and never shareable. A publichostname can resolve correctly and be useless, which is exactly the case thatmotivated the field: domainReachesBind checks that the name resolves to anaddress Traefik actually listens on, so a wildcard pointing at a host whoseTraefik is on loopback is reported as broken rather than offered as a URL. Datastores get one endpoint and no choices A service whose kind is not http returns after the internal endpoint,whatever providers are enabled. Postgres and Redis are reached over the accessnetwork or an SSH bridge; giving them an HTTP hostname would be offeringsomething that cannot work. This matches the refusal portta share alreadymakes. Amended 2026-09-02. A datastore whose protocol can be told apart byhostname (isHostnameRoutable / tcpEntrypoint) gets one endpoint perprovider that can carry it — local, lan, tailscale, auto-domain,custom-domain — as host:port, never as an HTTP URL. A kind whoseTcpRouting is unsupported or unevaluated still gets exactly theinternal endpoint. A live loopback bridge is a bridge endpoint withscope local. The rule this section was protecting stays: a datastore isnever given an HTTP hostname, and discovering an address never creates aroute. Consequences portta share becomes a special case of this, not a parallel system. Ashare is an endpoint with an expiry and a credential. It keeps its own file andits own commands; the model now has a place for it to belong. The panel can finally show the truth. \"This name resolves here, and Traefikis not listening on it\" is a sentence the old model could not form, so itprinted a URL instead. More state to detect, some of it over the network. Capability detection runsprobes — tailscale status, a Docker inspect, a log grep. It is read-only byconstruction and never authenticates anything, but it is not free, so it isgathered once per request rather than per service. Exposures are per service. A project with six services has six independentdecisions available. Everything defaults to off.","headings":[{"id":"0024-a-service-has-endpoints-not-an-access-mode","text":"0024. A service has endpoints, not an access mode","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":33},{"id":"capabilities-are-detected-never-configured","text":"Capabilities are detected, never configured","level":3,"line":43},{"id":"a-capability-is-not-an-exposure","text":"A capability is not an exposure","level":3,"line":69},{"id":"a-service-has-several-endpoints-at-once","text":"A service has several endpoints, at once","level":3,"line":77},{"id":"datastores-get-one-endpoint-and-no-choices","text":"Datastores get one endpoint and no choices","level":3,"line":100},{"id":"consequences","text":"Consequences","level":2,"line":118}],"kind":"markdown"},{"slug":"adr/0025-cloudflare-tunnel","title":"0025. One tunnel, one wildcard rule, and Traefik keeps routing","description":"0025. One tunnel, one wildcard rule, and Traefik keeps routing.","source":"docs/development/adr/0025-cloudflare-tunnel.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0025-cloudflare-tunnel","markdown":"# 0025. One tunnel, one wildcard rule, and Traefik keeps routing\n\n**Status:** Accepted\n\n## Context\n\nEvery way Portta could publish a service until now required the host to be\nreachable from outside: a public address, ports 80 and 443 open, and something\nlistening on them. That rules out the cases people most often have — a home lab,\na machine behind CGNAT, a laptop, a VPS whose provider firewalls inbound by\ndefault.\n\nCloudflare Tunnel inverts the direction: `cloudflared` dials **out** to\nCloudflare and traffic arrives back down that connection. The question was\nwhether it fits Portta's architecture or fights it, and specifically whether\npublishing a service through it would need a Cloudflare change **per service** —\nwhich would make it useless for a gateway whose whole premise is that starting a\ncontainer is enough.\n\n## What was measured\n\nNot assumed. On a real host, against a live tunnel, with `cloudflared`\n2026.8.3.\n\n**A wildcard ingress rule matches every derived hostname.**\n`cloudflared tunnel ingress validate` accepts `hostname: \"*.portta.app\"`, and\n`ingress rule` matched `web--demo.portta.app` and `api--demo--pr-42.portta.app`\nagainst that single rule, while `portta.app` and other zones fell through to the\ncatch-all. Wildcards are documented as one level and not usable mid-hostname,\nwhich is exactly what [ADR 0023](0023-flat-hostname-labels.md) already requires.\n\n**The Host header survives to the origin.** This is the linchpin, so it was\nverified end to end rather than read. Through a live tunnel from the public\ninternet, the container received:\n\n```\nHost: windsor-ipod-among-cst.trycloudflare.com\nCf-Connecting-Ip: 2804:1b2:…\nX-Forwarded-Host: windsor-ipod-among-cst.trycloudflare.com\n```\n\nThe original Host arrives unchanged, which is the only thing Traefik routes on.\n`httpHostHeader` would override it, so the generated configuration deliberately\nnever sets it.\n\n**Traefik routes behind the connector, dynamically.** With the tunnel already\nrunning and unchanged, the hostname returned Traefik's own 404. Adding a router\nmade it return 200 from the container; removing it made it stop. **No Cloudflare\nAPI call, no DNS change, no connector restart** — which is the whole hypothesis,\nconfirmed.\n\n**WebSocket works.** `HTTP/1.1 101 Switching Protocols` end to end, from the\npublic internet through the edge, the connector, and Traefik to the container.\n\n**Failure modes are distinguishable**, which matters for the troubleshooting\nguide:\n\n| What is broken | What the caller sees |\n|---|---|\n| origin container stopped | `502` |\n| connector stopped | `530`, Cloudflare \"Tunnel error\" (1033) |\n| nothing routed for that hostname | Traefik's own `404` |\n\n**Universal SSL covers the wildcard, for free.** `*.portta.app` is a first-level\nsubdomain and is covered; a second level is not, and needs a paid add-on. See\n[ADR 0023](0023-flat-hostname-labels.md).\n\n**Proxied wildcard DNS records are available on every plan.** Cloudflare's DNS\ndocumentation states it plainly: \"Customers on all plans can create and proxy\nwildcard DNS records.\"\n\n## Decision\n\n**Cloudflare Tunnel is an exposure provider, not the architecture.**\n\nIt takes its place beside the others in\n[ADR 0024](0024-capabilities-providers-endpoints.md) and replaces none of them.\nPublishing a port stays exactly as it was; a host that has a public address and\nwants to use it, does.\n\n**The connector carries one ingress rule for the whole gateway:**\n\n```yaml\ningress:\n - hostname: \"*.portta.app\"\n service: http://traefik:80\n - service: http_status:404\n```\n\nEverything dynamic happens below that line, inside Traefik, which already routes\nby Host and already learns about containers from Docker labels. Responsibilities\ndivide cleanly, with no overlap:\n\n| | |\n|---|---|\n| **Cloudflare** | TLS at the edge, the wildcard DNS record, Access policies |\n| **cloudflared** | one outbound connection; the wildcard rule; nothing per service |\n| **Traefik** | routing by Host, exactly as it does on every other profile |\n| **Portta** | generating the connector's config, running it, reporting its state |\n\n**The connector runs as a container.** Everything else the gateway runs is a\ncontainer, pinned to a version ([ADR 0004](0004-pinned-versions.md)), updated by\npulling an image. A host install would be the one component with a different\nlifecycle, update path and uninstall. It joins the shared `portta` network and\nnothing else, so it can reach Traefik by name and has no route into any\nproject's private network. An operator who already runs `cloudflared` under\nsystemd keeps it: detection finds that connector and the overlay stays off.\n\n**Credentials go in a file, never on a command line.** `tunnel run --config`\nrather than `--token`, because a token on a command line is visible in `ps` to\nevery user on the host. The credentials file is written `0600` beside the\ngenerated config and mounted read-only.\n\n**One route is written by hand, once.** `*.portta.app CNAME <uuid>.cfargotunnel.com`,\nproxied. The UUID is not a secret: `cfargotunnel.com` only accepts records from\nthe account that owns the tunnel.\n\n**Portta never touches the Cloudflare account.** It does not create tunnels, DNS\nrecords, or Access applications, and it does not hold an API token to do so.\n`portta tunnel setup` prints what to create and where; the operator creates it.\nThat is the same line [ADR 0007](0007-tailscale-sidecar.md) and `docs/tailscale.md`\nalready draw around a tailnet: Portta reports what somebody else's account\nallows, and changes nothing in it.\n\n## What this does not solve\n\n**Quick tunnels are a debugging aid, not a deployment.** `trycloudflare.com`\nneeds no account, which makes it excellent for exactly the verification above,\nand it hands out a **new hostname on every connector restart** — confirmed:\n`windsor-ipod-among-cst` became `proceeding-observed-discrete-cookbook`. It also\nbuffers `text/event-stream`, so Server-Sent Events never arrive. Named tunnels\nhave neither problem. Portta configures named tunnels only.\n\n**The apex is not covered.** `*.zone` does not match `zone`; verified. A gateway\nthat should answer on the apex adds a rule for it, which `includeApex` does.\n\n**Access is Cloudflare-side state.** Portta records that a policy exists so an\nendpoint can be labelled `protected` rather than `public`. It cannot verify one\nand does not try; a claim it could not check would be worse than no claim.\n\n**Latency and dependency.** Traffic goes through Cloudflare rather than to the\nhost, which was 0.36s from Brazil via a US data centre in testing. When\nCloudflare is down, so is the endpoint. Publishing a port has neither property,\nwhich is why both remain.\n\n## Consequences\n\n**A machine with no public address can publish HTTPS.** That is the case this\nexists for, and it needs no port open, no static address, and no certificate on\nthe host.\n\n**Adding a project stays a Docker operation.** No Cloudflare change, ever, after\nthe one-time setup. This is the property that made the feature worth building\nrather than documenting as a manual recipe.\n\n**The tunnel is the one public endpoint that ignores the bind address.** Every\nother public URL depends on where Traefik listens; this one does not, because\nthe connector reaches Traefik from inside the network. The endpoint model\nhandles it as a deliberate exception, with a comment saying why.\n\n**One more moving part, and one more outage surface.** A connector that stops\ntakes every tunnel hostname with it, and reports `530` rather than anything\nPortta can explain from the inside. `doctor` checks the connector's registered\nconnections for exactly this reason.\n","text":"0025. One tunnel, one wildcard rule, and Traefik keeps routing Status: Accepted Context Every way Portta could publish a service until now required the host to bereachable from outside: a public address, ports 80 and 443 open, and somethinglistening on them. That rules out the cases people most often have — a home lab,a machine behind CGNAT, a laptop, a VPS whose provider firewalls inbound bydefault. Cloudflare Tunnel inverts the direction: cloudflared dials out toCloudflare and traffic arrives back down that connection. The question waswhether it fits Portta's architecture or fights it, and specifically whetherpublishing a service through it would need a Cloudflare change per service —which would make it useless for a gateway whose whole premise is that starting acontainer is enough. What was measured Not assumed. On a real host, against a live tunnel, with cloudflared2026.8.3. A wildcard ingress rule matches every derived hostname.cloudflared tunnel ingress validate accepts hostname: \"*.portta.app\", andingress rule matched web--demo.portta.app and api--demo--pr-42.portta.appagainst that single rule, while portta.app and other zones fell through to thecatch-all. Wildcards are documented as one level and not usable mid-hostname,which is exactly what ADR 0023 already requires. The Host header survives to the origin. This is the linchpin, so it wasverified end to end rather than read. Through a live tunnel from the publicinternet, the container received: The original Host arrives unchanged, which is the only thing Traefik routes on.httpHostHeader would override it, so the generated configuration deliberatelynever sets it. Traefik routes behind the connector, dynamically. With the tunnel alreadyrunning and unchanged, the hostname returned Traefik's own 404. Adding a routermade it return 200 from the container; removing it made it stop. No CloudflareAPI call, no DNS change, no connector restart — which is the whole hypothesis,confirmed. WebSocket works. HTTP/1.1 101 Switching Protocols end to end, from thepublic internet through the edge, the connector, and Traefik to the container. Failure modes are distinguishable, which matters for the troubleshootingguide: What is broken What the caller sees origin container stopped 502 connector stopped 530, Cloudflare \"Tunnel error\" (1033) nothing routed for that hostname Traefik's own 404 Universal SSL covers the wildcard, for free. *.portta.app is a first-levelsubdomain and is covered; a second level is not, and needs a paid add-on. SeeADR 0023. Proxied wildcard DNS records are available on every plan. Cloudflare's DNSdocumentation states it plainly: \"Customers on all plans can create and proxywildcard DNS records.\" Decision Cloudflare Tunnel is an exposure provider, not the architecture. It takes its place beside the others inADR 0024 and replaces none of them.Publishing a port stays exactly as it was; a host that has a public address andwants to use it, does. The connector carries one ingress rule for the whole gateway: Everything dynamic happens below that line, inside Traefik, which already routesby Host and already learns about containers from Docker labels. Responsibilitiesdivide cleanly, with no overlap: Cloudflare TLS at the edge, the wildcard DNS record, Access policies cloudflared one outbound connection; the wildcard rule; nothing per service Traefik routing by Host, exactly as it does on every other profile Portta generating the connector's config, running it, reporting its state The connector runs as a container. Everything else the gateway runs is acontainer, pinned to a version (ADR 0004), updated bypulling an image. A host install would be the one component with a differentlifecycle, update path and uninstall. It joins the shared portta network andnothing else, so it can reach Traefik by name and has no route into anyproject's private network. An operator who already runs cloudflared undersystemd keeps it: detection finds that connector and the overlay stays off. Credentials go in a file, never on a command line. tunnel run --configrather than --token, because a token on a command line is visible in ps toevery user on the host. The credentials file is written 0600 beside thegenerated config and mounted read-only. One route is written by hand, once. *.portta.app CNAME <uuid>.cfargotunnel.com,proxied. The UUID is not a secret: cfargotunnel.com only accepts records fromthe account that owns the tunnel. Portta never touches the Cloudflare account. It does not create tunnels, DNSrecords, or Access applications, and it does not hold an API token to do so.portta tunnel setup prints what to create and where; the operator creates it.That is the same line ADR 0007 and docs/tailscale.mdalready draw around a tailnet: Portta reports what somebody else's accountallows, and changes nothing in it. What this does not solve Quick tunnels are a debugging aid, not a deployment. trycloudflare.comneeds no account, which makes it excellent for exactly the verification above,and it hands out a new hostname on every connector restart — confirmed:windsor-ipod-among-cst became proceeding-observed-discrete-cookbook. It alsobuffers text/event-stream, so Server-Sent Events never arrive. Named tunnelshave neither problem. Portta configures named tunnels only. The apex is not covered. *.zone does not match zone; verified. A gatewaythat should answer on the apex adds a rule for it, which includeApex does. Access is Cloudflare-side state. Portta records that a policy exists so anendpoint can be labelled protected rather than public. It cannot verify oneand does not try; a claim it could not check would be worse than no claim. Latency and dependency. Traffic goes through Cloudflare rather than to thehost, which was 0.36s from Brazil via a US data centre in testing. WhenCloudflare is down, so is the endpoint. Publishing a port has neither property,which is why both remain. Consequences A machine with no public address can publish HTTPS. That is the case thisexists for, and it needs no port open, no static address, and no certificate onthe host. Adding a project stays a Docker operation. No Cloudflare change, ever, afterthe one-time setup. This is the property that made the feature worth buildingrather than documenting as a manual recipe. The tunnel is the one public endpoint that ignores the bind address. Everyother public URL depends on where Traefik listens; this one does not, becausethe connector reaches Traefik from inside the network. The endpoint modelhandles it as a deliberate exception, with a comment saying why. One more moving part, and one more outage surface. A connector that stopstakes every tunnel hostname with it, and reports 530 rather than anythingPortta can explain from the inside. doctor checks the connector's registeredconnections for exactly this reason.","headings":[{"id":"0025-one-tunnel-one-wildcard-rule-and-traefik-keeps-routing","text":"0025. One tunnel, one wildcard rule, and Traefik keeps routing","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"what-was-measured","text":"What was measured","level":2,"line":19},{"id":"decision","text":"Decision","level":2,"line":71},{"id":"what-this-does-not-solve","text":"What this does not solve","level":2,"line":124},{"id":"consequences","text":"Consequences","level":2,"line":145}],"kind":"markdown"},{"slug":"adr/0026-applying-settings-from-the-panel","title":"0026. Applying panel settings uses one isolated applier","description":"0026. Applying settings from the panel is one opt-in container, outside the Compose project.","source":"docs/development/adr/0026-applying-settings-from-the-panel.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0026-applying-settings-from-the-panel","markdown":"# 0026. Applying panel settings uses one isolated applier\n\n**Status:** Accepted\n\nSettings that affect Compose take effect through one stopped, fixed-command\ncontainer prepared by the CLI when `PORTTA_APPLY=true`. The panel may start\nthat container but cannot choose its command or arguments.\n\nThe applier runs the installed Node CLI, mounts only the Docker socket and the\ninstallation directory, has no network, and is not part of the Compose project\nit recreates. Its specification label lets `portta up` replace a stale shape\nand preserve the exit status of the current run. Public panel modes do not\nprepare it.\n","text":"0026. Applying panel settings uses one isolated applier Status: Accepted Settings that affect Compose take effect through one stopped, fixed-commandcontainer prepared by the CLI when PORTTA_APPLY=true. The panel may startthat container but cannot choose its command or arguments. The applier runs the installed Node CLI, mounts only the Docker socket and theinstallation directory, has no network, and is not part of the Compose projectit recreates. Its specification label lets portta up replace a stale shapeand preserve the exit status of the current run. Public panel modes do notprepare it.","headings":[{"id":"0026-applying-panel-settings-uses-one-isolated-applier","text":"0026. Applying panel settings uses one isolated applier","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0027-forward-authentication-service","title":"0027. Protected application hosts use ForwardAuth","description":"0027. Protected HTTP access is checked by a separate ForwardAuth service.","source":"docs/development/adr/0027-forward-authentication-service.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0027-forward-authentication-service","markdown":"# 0027. Protected application hosts use ForwardAuth\n\n**Status:** Accepted\n\nProject hostnames and expiring shares can require credentials. Traefik asks the\nisolated `portta-auth` service before forwarding those requests. Credentials\nare scrypt hashes in `state/auth/protections.json`; plaintext is shown only when\ncreated. The service mounts that store read-only and has no Docker socket or\ndatabase access.\n\nLogin routers use a reserved path and never reference the authentication\nmiddleware. The middleware fails closed when the auth service or store is not\navailable. Panel sign-in is handled by the panel itself under ADR 0035.\n","text":"0027. Protected application hosts use ForwardAuth Status: Accepted Project hostnames and expiring shares can require credentials. Traefik asks theisolated portta-auth service before forwarding those requests. Credentialsare scrypt hashes in state/auth/protections.json; plaintext is shown only whencreated. The service mounts that store read-only and has no Docker socket ordatabase access. Login routers use a reserved path and never reference the authenticationmiddleware. The middleware fails closed when the auth service or store is notavailable. Panel sign-in is handled by the panel itself under ADR 0035.","headings":[{"id":"0027-protected-application-hosts-use-forwardauth","text":"0027. Protected application hosts use ForwardAuth","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0028-operational-images-live-under-docker","title":"0028. Operational image contexts live under docker/images/","description":"0028. Operational image contexts live under `docker/images/`.","source":"docs/development/adr/0028-operational-images-live-under-docker.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0028-operational-images-live-under-docker","markdown":"# 0028. Operational image contexts live under `docker/images/`\n\n**Status:** Accepted\n\n## Context\n\nThe Compose matrix and the runnable examples already lived under `docker/`,\nbut the build contexts for two runtime-owned helper images remained as\nroot-level `apply/` and `toolbox/` directories. Neither is an application or\nan npm workspace: each contains only a Dockerfile and exists solely to produce\nan operational image. Their placement made the repository root look as if it\ncontained two more product subsystems and left Docker assets split across\nunrelated levels.\n\nThe panel Dockerfile is different. It belongs to the application lifecycle,\nhas development and runtime stages, and builds `apps/web` and `apps/auth` from\nthe repository-root workspace context.\n\n## Decision\n\nRuntime-owned, self-contained image contexts live under `docker/images/`:\n\n```text\ndocker/\n├── compose/ gateway base and overlays\n├── images/\n│ ├── apply/ settings applier image context\n│ └── toolbox/ operational toolbox image context\n└── examples/ self-contained demonstration stacks\n```\n\nApplication Dockerfiles remain colocated with their applications, so the\npanel stays at `apps/web/Dockerfile`.\n\nThe npm runtime contains `docker/compose/` and `docker/images/`, but not\n`docker/examples/`. CLI commands remain the stable interface.\n\n## Consequences\n\nThe repository root contains product subsystems rather than incidental Docker\nbuild contexts. All Docker-owned runtime assets are discoverable below one\ndirectory without separating an application's image definition from its code.\n\nManual builds must use `docker/images/apply/` and `docker/images/toolbox/`.\nImage names, versions, CLI commands and runtime behavior do not change.\n","text":"0028. Operational image contexts live under docker/images/ Status: Accepted Context The Compose matrix and the runnable examples already lived under docker/,but the build contexts for two runtime-owned helper images remained asroot-level apply/ and toolbox/ directories. Neither is an application oran npm workspace: each contains only a Dockerfile and exists solely to producean operational image. Their placement made the repository root look as if itcontained two more product subsystems and left Docker assets split acrossunrelated levels. The panel Dockerfile is different. It belongs to the application lifecycle,has development and runtime stages, and builds apps/web and apps/auth fromthe repository-root workspace context. Decision Runtime-owned, self-contained image contexts live under docker/images/: Application Dockerfiles remain colocated with their applications, so thepanel stays at apps/web/Dockerfile. The npm runtime contains docker/compose/ and docker/images/, but notdocker/examples/. CLI commands remain the stable interface. Consequences The repository root contains product subsystems rather than incidental Dockerbuild contexts. All Docker-owned runtime assets are discoverable below onedirectory without separating an application's image definition from its code. Manual builds must use docker/images/apply/ and docker/images/toolbox/.Image names, versions, CLI commands and runtime behavior do not change.","headings":[{"id":"0028-operational-image-contexts-live-under-dockerimages","text":"0028. Operational image contexts live under docker/images/","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":18},{"id":"consequences","text":"Consequences","level":2,"line":37}],"kind":"markdown"},{"slug":"adr/0029-product-behavior-lives-in-typescript","title":"0029. Product behavior lives in TypeScript","description":"0029. Product behavior lives in TypeScript.","source":"docs/development/adr/0029-product-behavior-lives-in-typescript.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0029-product-behavior-lives-in-typescript","markdown":"# 0029. Product behavior lives in TypeScript\n\n**Status:** Accepted\n\n## Decision\n\nThe `portta` package is the only command implementation. Product shell is\nlimited to a small installer handoff and the fixed entrypoint of the isolated\nrunner image. Shell tests may drive real Docker scenarios but do not provide\nruntime behavior.\n\nCalls to Docker, Git, SSH, OpenSSL and other programs use the CLI process\nadapter with explicit argument arrays. A command, parser, validation rule or\nconfiguration writer must not be copied into shell.\n\n## Consequences\n\n- `packages/cli/src/cli.ts` is the canonical command tree.\n- Removing or renaming a command happens there and in its consumers together.\n- `docs/development/scripts.md` is the live inventory of permitted shell\n boundaries.\n","text":"0029. Product behavior lives in TypeScript Status: Accepted Decision The portta package is the only command implementation. Product shell islimited to a small installer handoff and the fixed entrypoint of the isolatedrunner image. Shell tests may drive real Docker scenarios but do not provideruntime behavior. Calls to Docker, Git, SSH, OpenSSL and other programs use the CLI processadapter with explicit argument arrays. A command, parser, validation rule orconfiguration writer must not be copied into shell. Consequences packages/cli/src/cli.ts is the canonical command tree. Removing or renaming a command happens there and in its consumers together. docs/development/scripts.md is the live inventory of permitted shellboundaries.","headings":[{"id":"0029-product-behavior-lives-in-typescript","text":"0029. Product behavior lives in TypeScript","level":1,"line":0},{"id":"decision","text":"Decision","level":2,"line":4},{"id":"consequences","text":"Consequences","level":2,"line":15}],"kind":"markdown"},{"slug":"adr/0030-the-panel-and-a-project-lifecycle","title":"0030. The panel may operate a project, without owning it","description":"0030. The panel may operate a project, without owning it.","source":"docs/development/adr/0030-the-panel-and-a-project-lifecycle.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0030-the-panel-and-a-project-lifecycle","markdown":"# 0030. The panel may operate a project, without owning it\n\n**Status:** Accepted, amends [0001](0001-decoupled-infrastructure.md)\n\n## Context\n\n[ADR 0001](0001-decoupled-infrastructure.md) forbids the gateway from taking\npart in a consumer project's lifecycle. That sentence is still the right\n*ownership* claim: Portta does not move projects, mount their directories into\nthe gateway, own their volumes, or run `docker system prune`.\n\nThe *practice* has already moved. The panel starts, stops, restarts and removes\none container at a time ([ADR 0008](0008-web-panel-socket-proxy.md)), with\n`assertNotGatewayOwned` refusing Portta's own components and `removeContainer`\nhard-coding `v=0`. Four features that follow from a remote development host —\nturning a whole project off, rebuilding it, removing it from this host, starting\na per-workspace editor — cannot be built against the letter of ADR 0001, and\ncannot be built by widening the socket proxy either. [ADR 0026](0026-applying-settings-from-the-panel.md)\nalready rejected that: resolving overlays against `.env` is reimplementing\nCompose inside the panel.\n\nThis record redraws the line the code already makes, and names one mechanism\nfor the operations Compose itself must perform.\n\n## Decision\n\n**Portta does not own a project. It may operate one, on request.**\n\n| | Portta may | Portta may not |\n|---|---|---|\n| **Runtime state** | start, stop, restart containers it can see | — |\n| **Composition** | rebuild and recreate on the operator's explicit request | change a project's Compose files |\n| **Data** | — | remove a volume unless the operator names the project to confirm |\n| **Anything remote** | — | ever, under any operation |\n\nThe load-bearing sentence kept from ADR 0001 is the ownership one. Acquiring\nthe ability to operate containers on request is a different claim, and the one\nthe panel already makes per container.\n\n### One runner, still opt-in, still a fixed command\n\n`portta up` prepares a second container, stopped, when `PORTTA_RUNNER=true`.\nIt follows ADR 0026 exactly:\n\n- runs on the host, with the Docker socket, the Portta root, and the host\n filesystem mounted at `/host`, labelled `portta.managed=true` and\n `portta.component=runner`;\n- accepts a **verb from a closed set**, never a command line — `up`, `stop`,\n `restart`, `build`, `down`, `down-volumes` — plus a project name;\n- is gated behind `PORTTA_RUNNER`, which is absent from the panel's field\n catalogue, the way `PORTTA_APPLY` is;\n- reports outcome by having the panel read the container back (state, exit\n code, log tail since `StartedAt`).\n\nThe panel's Docker permission stays `POST /containers/{id}/start` and read.\n`docker/compose/features/web.yaml` and the in-process allowlist are not\nwidened. Adding a verb to the set is an ADR-level change, not a patch.\n\nThe request the runner reads is `{ verb, project, flags? }` in\n`state/runner/request.json`. Flags are a closed set too: `no-cache` is valid\nonly with `build`; `directory` is valid only with `down-volumes`. Adding a\nflag is the same class of change as adding a verb. The working directory and\nCompose files come from Docker's own labels\n(`com.docker.compose.project.working_dir`, `.config_files`), never from a\npath the panel supplied. The runner translates those host paths through\n`/host` so it can read the files; `--project-directory` stays the host path,\nbecause Compose hands bind mounts to the daemon.\n\nDirectory removal is the runner's job, not the panel's. The path is the\nlabel, validated as an existing directory that is not `/`, not a top-level\ndirectory, and does not walk up. A dirty working tree is refused unless the\noperator overrides after seeing the counts.\n\n### Where a project lives\n\nLabels are the primary source: they are the project's own truth and need no\nregistration. A project whose working-directory label is missing is **not\noperable**, shown with a reason. Workspace records do not override the path in\nthis decision; an override would be a path the operator typed, and that is a\nlater change.\n\n### The safety envelope\n\n- A verb that destroys data (`down-volumes`, directory removal) requires the\n **project name typed back**, checked on the server.\n- Portta's own components are refused by name, reusing `assertNotGatewayOwned`.\n- No operation ever touches a Git remote, a GitHub repository, an issue or a\n branch.\n- Every operation is logged where the operator can read it back.\n- The working directory is validated as an existing directory and never\n concatenated into a shell string.\n\nWhat ADR 0001 still forbids, unchanged: moving or cloning projects, mounting\nproject directories into the **gateway**, owning volumes, and any `docker *\nprune`.\n\n## Consequences\n\nFour features share one mechanism and one place to audit. A host that never\nsets `PORTTA_RUNNER` has zero new attack surface; the panel renders that\nabsence with a reason, as `whyUnavailable` already does for the applier.\n\nThe runner is the largest privilege in the system: a root container with the\nDocker socket and a view of the host filesystem. The closed verb set, the\nopt-in key, and the project name validated against the live snapshot are what\nkeep it bounded. A project started outside Compose has no working-directory\nlabel and is reported as not operable rather than offered a dead button.\n\n## Amended 2026-09-03: a remembered environment, and `include:`\n\nA remembered Environment remains in the panel after its containers are gone.\nTwo rules support that state without widening the runner.\n\n**`up` carries the paths when no container exists.** The snapshot records\n`config_files` beside `working_dir` in `environments`, and a remembered\nEnvironment is a full `Environment` with no services and\n`presence: remembered`. Start on one hands the runner\n`{ verb: \"up\", project, workingDir, configFiles }`. The panel validates both\npaths against the bound `remove_working_dir` already applies (absolute, no\n`..`, not `/`, not a top-level directory) and refuses a comma, a quote or a\nbackslash, which is what lets the shell read the list as text. The runner\nuses those fields **only** when `docker ps` finds no container for the\nproject; with one, labels win exactly as before, and any verb but `up` with\nno container still dies. Portta's own project is refused by name, from\n`PORTTA_PROJECT_NAME` in `.env`, and by directory, `PORTTA_ROOT`. Without\nthe runner, the answer is a 409 whose hint is the exact `docker compose …\nup -d` to run on the host. Forgetting a remembered Environment drops its row;\na live one is refused.\n\n**Host paths are linked under the runner so `include:` resolves.** The runner\nlinks the first missing ancestor of the working directory, and of every\nCompose file outside it, to its `/host` counterpart, and hands Compose the host\npaths with `--project-directory` unchanged. An ancestor that exists in the\nimage and is not that link is an error, stated, never a silent misread. The\nrepository root is already mounted at its own path and needs no link.\n","text":"0030. The panel may operate a project, without owning it Status: Accepted, amends 0001 Context ADR 0001 forbids the gateway from takingpart in a consumer project's lifecycle. That sentence is still the rightownership claim: Portta does not move projects, mount their directories intothe gateway, own their volumes, or run docker system prune. The practice has already moved. The panel starts, stops, restarts and removesone container at a time (ADR 0008), withassertNotGatewayOwned refusing Portta's own components and removeContainerhard-coding v=0. Four features that follow from a remote development host —turning a whole project off, rebuilding it, removing it from this host, startinga per-workspace editor — cannot be built against the letter of ADR 0001, andcannot be built by widening the socket proxy either. ADR 0026already rejected that: resolving overlays against .env is reimplementingCompose inside the panel. This record redraws the line the code already makes, and names one mechanismfor the operations Compose itself must perform. Decision Portta does not own a project. It may operate one, on request. Portta may Portta may not Runtime state start, stop, restart containers it can see — Composition rebuild and recreate on the operator's explicit request change a project's Compose files Data — remove a volume unless the operator names the project to confirm Anything remote — ever, under any operation The load-bearing sentence kept from ADR 0001 is the ownership one. Acquiringthe ability to operate containers on request is a different claim, and the onethe panel already makes per container. One runner, still opt-in, still a fixed command portta up prepares a second container, stopped, when PORTTA_RUNNER=true.It follows ADR 0026 exactly: runs on the host, with the Docker socket, the Portta root, and the hostfilesystem mounted at /host, labelled portta.managed=true andportta.component=runner; accepts a verb from a closed set, never a command line — up, stop,restart, build, down, down-volumes — plus a project name; is gated behind PORTTA_RUNNER, which is absent from the panel's fieldcatalogue, the way PORTTA_APPLY is; reports outcome by having the panel read the container back (state, exitcode, log tail since StartedAt). The panel's Docker permission stays POST /containers/{id}/start and read.docker/compose/features/web.yaml and the in-process allowlist are notwidened. Adding a verb to the set is an ADR-level change, not a patch. The request the runner reads is { verb, project, flags? } instate/runner/request.json. Flags are a closed set too: no-cache is validonly with build; directory is valid only with down-volumes. Adding aflag is the same class of change as adding a verb. The working directory andCompose files come from Docker's own labels(com.docker.compose.project.working_dir, .config_files), never from apath the panel supplied. The runner translates those host paths through/host so it can read the files; --project-directory stays the host path,because Compose hands bind mounts to the daemon. Directory removal is the runner's job, not the panel's. The path is thelabel, validated as an existing directory that is not /, not a top-leveldirectory, and does not walk up. A dirty working tree is refused unless theoperator overrides after seeing the counts. Where a project lives Labels are the primary source: they are the project's own truth and need noregistration. A project whose working-directory label is missing is notoperable, shown with a reason. Workspace records do not override the path inthis decision; an override would be a path the operator typed, and that is alater change. The safety envelope A verb that destroys data (down-volumes, directory removal) requires theproject name typed back, checked on the server. Portta's own components are refused by name, reusing assertNotGatewayOwned. No operation ever touches a Git remote, a GitHub repository, an issue or abranch. Every operation is logged where the operator can read it back. The working directory is validated as an existing directory and neverconcatenated into a shell string. What ADR 0001 still forbids, unchanged: moving or cloning projects, mountingproject directories into the gateway, owning volumes, and any docker * prune. Consequences Four features share one mechanism and one place to audit. A host that neversets PORTTA_RUNNER has zero new attack surface; the panel renders thatabsence with a reason, as whyUnavailable already does for the applier. The runner is the largest privilege in the system: a root container with theDocker socket and a view of the host filesystem. The closed verb set, theopt-in key, and the project name validated against the live snapshot are whatkeep it bounded. A project started outside Compose has no working-directorylabel and is reported as not operable rather than offered a dead button. Amended 2026-09-03: a remembered environment, and include: A remembered Environment remains in the panel after its containers are gone.Two rules support that state without widening the runner. up carries the paths when no container exists. The snapshot recordsconfig_files beside working_dir in environments, and a rememberedEnvironment is a full Environment with no services andpresence: remembered. Start on one hands the runner{ verb: \"up\", project, workingDir, configFiles }. The panel validates bothpaths against the bound remove_working_dir already applies (absolute, no.., not /, not a top-level directory) and refuses a comma, a quote or abackslash, which is what lets the shell read the list as text. The runneruses those fields only when docker ps finds no container for theproject; with one, labels win exactly as before, and any verb but up withno container still dies. Portta's own project is refused by name, fromPORTTA_PROJECT_NAME in .env, and by directory, PORTTA_ROOT. Withoutthe runner, the answer is a 409 whose hint is the exact docker compose … up -d to run on the host. Forgetting a remembered Environment drops its row;a live one is refused. Host paths are linked under the runner so include: resolves. The runnerlinks the first missing ancestor of the working directory, and of everyCompose file outside it, to its /host counterpart, and hands Compose the hostpaths with --project-directory unchanged. An ancestor that exists in theimage and is not that link is an error, stated, never a silent misread. Therepository root is already mounted at its own path and needs no link.","headings":[{"id":"0030-the-panel-may-operate-a-project-without-owning-it","text":"0030. The panel may operate a project, without owning it","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":24},{"id":"one-runner-still-opt-in-still-a-fixed-command","text":"One runner, still opt-in, still a fixed command","level":3,"line":39},{"id":"where-a-project-lives","text":"Where a project lives","level":3,"line":73},{"id":"the-safety-envelope","text":"The safety envelope","level":3,"line":81},{"id":"consequences","text":"Consequences","level":2,"line":96},{"id":"amended-2026-09-03-a-remembered-environment-and-include","text":"Amended 2026-09-03: a remembered environment, and include:","level":2,"line":108}],"kind":"markdown"},{"slug":"adr/0031-projects-home-and-project","title":"0031. Projects, repositories and environments are distinct","description":"0031. Projects Home, Project, and Environment.","source":"docs/development/adr/0031-projects-home-and-project.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0031-projects-home-and-project","markdown":"# 0031. Projects, repositories and environments are distinct\n\n**Status:** Accepted\n\nA Project is the product being developed and is persisted by the panel. It can\nown multiple repositories, tasks and running environments. A Repository is a\ncode location with optional GitHub metadata. An Environment is a Compose\nproject observed on the host.\n\n`PORTTA_PROJECTS_HOME` names the root under which the CLI discovers managed\nrepositories. The panel receives normalized project and repository coordinates;\nit does not mount the whole directory. Environment adoption is explicit or\nderived from the current `portta.project` label and repository coordinates.\n\nThe public surfaces use `/api/projects`, `/api/environments`, `portta projects`\nand `portta envs` consistently.\n","text":"0031. Projects, repositories and environments are distinct Status: Accepted A Project is the product being developed and is persisted by the panel. It canown multiple repositories, tasks and running environments. A Repository is acode location with optional GitHub metadata. An Environment is a Composeproject observed on the host. PORTTA_PROJECTS_HOME names the root under which the CLI discovers managedrepositories. The panel receives normalized project and repository coordinates;it does not mount the whole directory. Environment adoption is explicit orderived from the current portta.project label and repository coordinates. The public surfaces use /api/projects, /api/environments, portta projectsand portta envs consistently.","headings":[{"id":"0031-projects-repositories-and-environments-are-distinct","text":"0031. Projects, repositories and environments are distinct","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0032-portta-development-model","title":"0032. The Portta development model","description":"0032. The Portta development model.","source":"docs/development/adr/0032-portta-development-model.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0032-portta-development-model","markdown":"# 0032. The Portta development model\n\n**Status:** Accepted, amended by [0033](0033-tasks-are-local-issues.md) and by [0038](0038-roles-and-project-access.md) — a Project is now also a boundary, with members; amends [0010](0010-git-collected-on-the-host.md),\n[0013](0013-what-the-panel-persists.md), [0018](0018-github-access-lives-in-the-panel.md)\nand [0031](0031-projects-home-and-project.md)\n\n## Context\n\n[ADR 0031](0031-projects-home-and-project.md) settled the words: a Project\nis what is being developed, a Repository is its Git, an Environment is one\nexecution of it on this Node. It left the persistence rename for later and\ndid not say what a Project *contains* beyond repositories and environments.\n\nThree things the product had grown were still shaped by their first\nimplementation rather than by the work they serve:\n\n- **A Repository existed only through the GitHub App.** The `Repository`\n type was never populated; the only repositories a Project could own were\n rows of `github_repositories`. A Project with a local clone and no App had\n no repository, and therefore no board, no tasks and no MCP.\n- **A Task was a projected GitHub issue.** `core/tasks.ts` was a view over\n `github_issues`; status and priority were labels on GitHub; every task verb\n called `requireGitHub()`. Offline, or on a repository the App was not\n installed on, there was no work to do because there was nowhere to write it.\n- **Nothing remembered what happened.** `X-Portta-Actor` went to stdout,\n SSE was ephemeral, and \"who is working on what, since when, and what did\n they produce\" had no answer the panel could give from another machine.\n\nThe centre of the experience has to be the Project and its development\ncycle — demand, code, execution, test, analysis, correction, completion — for\na person and for an agent, through the UI, the API and the CLI alike.\nInfrastructure stays; it is reorganised around that.\n\n## Decision\n\n> **Portta organises technology around the work of development, not the\n> work of development around the infrastructure.**\n\n### The model\n\n```text\nNode (Projects Home)\n└── Project ──────────────── Tasks (parent → subtasks) ── GitHub Issue binding, optional\n ├── Repositories ─────── Git · recent commits · instruction files · pull requests\n ├── Environments ─────── Services ── Containers · endpoints · logs · resources\n ├── Development Sessions (actor × task × repository × environment)\n ├── Activity (what happened, with references to all of the above)\n └── Effective instructions = Platform + Project + Repository + Task\n```\n\nInvariants the code must keep:\n\n1. **Everything above exists without GitHub.** GitHub adds a binding, pull\n requests and synchronisation. It never defines whether a Repository or a\n Task exists.\n2. **Project, Repository and Task are decisions.** They are persisted.\n Environment, Service, Container, Git state and resources are observations:\n read from Docker and from files the host wrote, with their age on screen.\n3. **A Task belongs to one Project**, and optionally to a Repository, an\n Environment and a Service. A Subtask is a Task with a parent in the same\n Project.\n4. **A Repository belongs to exactly one Project.** ADR 0018 allowed one\n GitHub repository in several workspaces; nothing depended on it and it\n made Task, Activity and resource attribution ambiguous. The tightening\n ADR 0031 announced lands here.\n5. **Every write that matters is an activity event with an actor.**\n\n### Persistence\n\nThe rename ADR 0031 deferred is done: `workspaces → projects`,\n`projects → environments`, with their settings tables. New tables:\n`repositories`, `tasks`, `task_notes`, `task_github_links`,\n`task_environments` (ex-`issue_environments`), `dev_sessions` and\n`activity_events`. Migrations `0007` to `0010`.\n\nThe status vocabulary stays `backlog, ready, in_progress, review, blocked,\ndone`: it is what the `status:*` label convention already spells, so a bound\nissue and a local task read the same. The UI may call `ready` \"To do\".\n\n### GitHub is a binding, not the base\n\nA projected issue is still a cache with an age (ADR 0018 §4). What changes\nis what it feeds: a `task_github_links` row ties one Task to one issue. A\nwrite to every Task is committed locally first. A bound Task then attempts an\nexplicitly defined push to GitHub; failure leaves the local write intact and\nmarks the binding `pending` or `error` for retry. A remote change that\nlands on a pending local edit is a `conflict`, kept and shown, never silently\nresolved. Existing issues on a repository a Project owns became Tasks in the\nmigration, so no board was lost.\n\n### What the host collects, amended\n\nADR 0010 said *metadata only: never a diff, never a file's contents, never a\ncommit list beyond HEAD*. Two of those three are lifted, narrowly:\n\n- **The last twenty commits, as metadata** (sha, subject, author, date).\n Reviewing what an agent produced without a terminal is the point of the\n Task page; a link to the forge does not work for a repository that has no\n forge.\n- **The content of the instruction files an agent reads** — `AGENTS.md`,\n `CLAUDE.md`, `GEMINI.md`, `CONVENTIONS.md`, `.clinerules`, `.cursorrules`,\n `.windsurfrules`, `.github/copilot-instructions.md`, `.cursor/rules/*.mdc`\n — from that allowlist and nowhere else, bounded at 64 KiB per file, with\n a hash and a dirty flag. Never a `.env`, never a diff, never an arbitrary\n path. `packages/core/src/repos-scan.ts` is the allowlist; a test asserts a\n `.env` next to an `AGENTS.md` stays out.\n\nCollection is keyed by repository (the realpath of the git root), not by\nCompose project, and an index maps each environment to the repository it\nruns from. The metrics watcher runs the scan once a minute, so freshness no\nlonger depends on somebody running `portta up`. The panel still mounts no\nproject directory and runs no command.\n\n### One API, three clients\n\nThe UI, the CLI and an agent (through `portta mcp`) use the same endpoints:\n`/api/projects`, `/api/repositories`, `/api/tasks`, `/api/environments`,\n`/api/sessions`, `/api/activity`, `/api/overview`, and\n`/api/projects/:slug/context` — the Development Context an agent reads\nbefore it starts. Every route declares a capability from\n`packages/core/src/capabilities-api.ts`, published in the OpenAPI document\nas `x-portta-capability`. A request carries a principal: the operator\n(everything), read-only mode (every `*:read`), or an agent that announced\nitself with `X-Portta-Actor` (the `agentCapabilities` setting; by default\neverything except `*:destroy`, `config:write`, `access:write` and\n`gateway:operate`). Revocable bearer tokens can carry an actor and a subset\nof those capabilities; HTTP Basic remains available for operators and older\nclients.\n\n## Consequences\n\nA Project with no GitHub has repositories, tasks, a board, sessions, an\nactivity timeline and a working MCP. Connecting the App later binds tasks\nto issues without losing anything.\n\nThe panel is now an inventory of what is being worked on, by whom, and of\nthe instructions agents follow. ADR 0012's ordering — authentication before\nany of this — still stands, and the collected instruction files are one more\nreason the collected directory is `0700`/`0600`.\n\nTwo more tables persist decisions and two persist a bounded history.\nActivity is pruned in code (ninety days, five thousand rows per Project)\nrather than kept forever; it answers \"what happened this week\", not audit.\n\nWhat this record deliberately does not build: a file browser beyond\ninstruction files, GitHub Projects v2 or multiple hosts.\nThe model above is what makes each of them an addition rather than a\nrewrite.\n","text":"0032. The Portta development model Status: Accepted, amended by 0033 and by 0038 — a Project is now also a boundary, with members; amends 0010,0013, 0018and 0031 Context ADR 0031 settled the words: a Projectis what is being developed, a Repository is its Git, an Environment is oneexecution of it on this Node. It left the persistence rename for later anddid not say what a Project contains beyond repositories and environments. Three things the product had grown were still shaped by their firstimplementation rather than by the work they serve: A Repository existed only through the GitHub App. The Repositorytype was never populated; the only repositories a Project could own wererows of github_repositories. A Project with a local clone and no App hadno repository, and therefore no board, no tasks and no MCP. A Task was a projected GitHub issue. core/tasks.ts was a view overgithub_issues; status and priority were labels on GitHub; every task verbcalled requireGitHub(). Offline, or on a repository the App was notinstalled on, there was no work to do because there was nowhere to write it. Nothing remembered what happened. X-Portta-Actor went to stdout,SSE was ephemeral, and \"who is working on what, since when, and what didthey produce\" had no answer the panel could give from another machine. The centre of the experience has to be the Project and its developmentcycle — demand, code, execution, test, analysis, correction, completion — fora person and for an agent, through the UI, the API and the CLI alike.Infrastructure stays; it is reorganised around that. Decision Portta organises technology around the work of development, not thework of development around the infrastructure. The model Invariants the code must keep: Everything above exists without GitHub. GitHub adds a binding, pullrequests and synchronisation. It never defines whether a Repository or aTask exists. Project, Repository and Task are decisions. They are persisted.Environment, Service, Container, Git state and resources are observations:read from Docker and from files the host wrote, with their age on screen. A Task belongs to one Project, and optionally to a Repository, anEnvironment and a Service. A Subtask is a Task with a parent in the sameProject. A Repository belongs to exactly one Project. ADR 0018 allowed oneGitHub repository in several workspaces; nothing depended on it and itmade Task, Activity and resource attribution ambiguous. The tighteningADR 0031 announced lands here. Every write that matters is an activity event with an actor. Persistence The rename ADR 0031 deferred is done: workspaces → projects,projects → environments, with their settings tables. New tables:repositories, tasks, task_notes, task_github_links,task_environments (ex-issue_environments), dev_sessions andactivity_events. Migrations 0007 to 0010. The status vocabulary stays backlog, ready, in_progress, review, blocked, done: it is what the status:* label convention already spells, so a boundissue and a local task read the same. The UI may call ready \"To do\". GitHub is a binding, not the base A projected issue is still a cache with an age (ADR 0018 §4). What changesis what it feeds: a task_github_links row ties one Task to one issue. Awrite to every Task is committed locally first. A bound Task then attempts anexplicitly defined push to GitHub; failure leaves the local write intact andmarks the binding pending or error for retry. A remote change thatlands on a pending local edit is a conflict, kept and shown, never silentlyresolved. Existing issues on a repository a Project owns became Tasks in themigration, so no board was lost. What the host collects, amended ADR 0010 said metadata only: never a diff, never a file's contents, never acommit list beyond HEAD. Two of those three are lifted, narrowly: The last twenty commits, as metadata (sha, subject, author, date).Reviewing what an agent produced without a terminal is the point of theTask page; a link to the forge does not work for a repository that has noforge. The content of the instruction files an agent reads — AGENTS.md,CLAUDE.md, GEMINI.md, CONVENTIONS.md, .clinerules, .cursorrules,.windsurfrules, .github/copilot-instructions.md, .cursor/rules/*.mdc— from that allowlist and nowhere else, bounded at 64 KiB per file, witha hash and a dirty flag. Never a .env, never a diff, never an arbitrarypath. packages/core/src/repos-scan.ts is the allowlist; a test asserts a.env next to an AGENTS.md stays out. Collection is keyed by repository (the realpath of the git root), not byCompose project, and an index maps each environment to the repository itruns from. The metrics watcher runs the scan once a minute, so freshness nolonger depends on somebody running portta up. The panel still mounts noproject directory and runs no command. One API, three clients The UI, the CLI and an agent (through portta mcp) use the same endpoints:/api/projects, /api/repositories, /api/tasks, /api/environments,/api/sessions, /api/activity, /api/overview, and/api/projects/:slug/context — the Development Context an agent readsbefore it starts. Every route declares a capability frompackages/core/src/capabilities-api.ts, published in the OpenAPI documentas x-portta-capability. A request carries a principal: the operator(everything), read-only mode (every *:read), or an agent that announceditself with X-Portta-Actor (the agentCapabilities setting; by defaulteverything except *:destroy, config:write, access:write andgateway:operate). Revocable bearer tokens can carry an actor and a subsetof those capabilities; HTTP Basic remains available for operators and olderclients. Consequences A Project with no GitHub has repositories, tasks, a board, sessions, anactivity timeline and a working MCP. Connecting the App later binds tasksto issues without losing anything. The panel is now an inventory of what is being worked on, by whom, and ofthe instructions agents follow. ADR 0012's ordering — authentication beforeany of this — still stands, and the collected instruction files are one morereason the collected directory is 0700/0600. Two more tables persist decisions and two persist a bounded history.Activity is pruned in code (ninety days, five thousand rows per Project)rather than kept forever; it answers \"what happened this week\", not audit. What this record deliberately does not build: a file browser beyondinstruction files, GitHub Projects v2 or multiple hosts.The model above is what makes each of them an addition rather than arewrite.","headings":[{"id":"0032-the-portta-development-model","text":"0032. The Portta development model","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":6},{"id":"decision","text":"Decision","level":2,"line":33},{"id":"the-model","text":"The model","level":3,"line":38},{"id":"persistence","text":"Persistence","level":3,"line":67},{"id":"github-is-a-binding-not-the-base","text":"GitHub is a binding, not the base","level":3,"line":79},{"id":"what-the-host-collects-amended","text":"What the host collects, amended","level":3,"line":90},{"id":"one-api-three-clients","text":"One API, three clients","level":3,"line":113},{"id":"consequences","text":"Consequences","level":2,"line":129}],"kind":"markdown"},{"slug":"adr/0033-tasks-are-local-issues","title":"0033. Tasks are local issues with sparse board ranks and API credentials","description":"0033. Tasks are local issues with sparse board ranks and API credentials.","source":"docs/development/adr/0033-tasks-are-local-issues.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0033-tasks-are-local-issues","markdown":"# 0033. Tasks are local issues with sparse board ranks and API credentials\n\n**Status:** Accepted, amends [0032](0032-portta-development-model.md)\n\n## Context\n\nThe local Task model existed, but its board did not persist card order, writes\ncould replace an entire row after reading it, comments meant two different\nthings, and the remote CLI still depended on the panel's shared Basic\ncredential. These gaps made the UI, CLI and GitHub binding behave like\ndifferent products.\n\n## Decision\n\n- A Task is the canonical issue. GitHub remains an optional binding.\n- A local write commits before any GitHub request. Failure sets the binding to\n `pending` or `error`; it never rolls the Task back.\n- Linking an existing issue requires an explicit initial direction: `pull`\n imports the issue fields, while `push` publishes the Task fields.\n- The shared fields are title, description, status, priority, labels and\n assignee. Type, agent, due date, parent, repository/environment/service,\n board rank and local comments remain local.\n- Comments are local by default. Publishing one creates an explicit copy on\n GitHub and records that copy's id, URL and retry state.\n- Board order is a `BIGINT` sparse rank. Ranks are 1024 apart; a move writes\n one row and only rebalances the destination column when no integer gap is\n left. An advisory transaction lock serializes ranking within a column.\n- Common changes use partial `PATCH`; card movement uses the dedicated move\n endpoint with its adjacent task ids.\n- Remote clients authenticate with revocable Bearer tokens. A token has an\n actor and capability set, no implicit expiry, is shown once, and only its\n SHA-256 digest is persisted. Existing Basic and browser session auth remain.\n\n## Consequences\n\nThe browser, CLI, MCP and direct API calls exercise the same validation,\nactivity and GitHub synchronization path. A GitHub outage degrades only the\nbinding. Tokens can be revoked without rotating the panel password. Sparse\nranks avoid rewriting a column on ordinary moves, while the rare rebalance is\nbounded to one project/status column.\n","text":"0033. Tasks are local issues with sparse board ranks and API credentials Status: Accepted, amends 0032 Context The local Task model existed, but its board did not persist card order, writescould replace an entire row after reading it, comments meant two differentthings, and the remote CLI still depended on the panel's shared Basiccredential. These gaps made the UI, CLI and GitHub binding behave likedifferent products. Decision A Task is the canonical issue. GitHub remains an optional binding. A local write commits before any GitHub request. Failure sets the binding topending or error; it never rolls the Task back. Linking an existing issue requires an explicit initial direction: pullimports the issue fields, while push publishes the Task fields. The shared fields are title, description, status, priority, labels andassignee. Type, agent, due date, parent, repository/environment/service,board rank and local comments remain local. Comments are local by default. Publishing one creates an explicit copy onGitHub and records that copy's id, URL and retry state. Board order is a BIGINT sparse rank. Ranks are 1024 apart; a move writesone row and only rebalances the destination column when no integer gap isleft. An advisory transaction lock serializes ranking within a column. Common changes use partial PATCH; card movement uses the dedicated moveendpoint with its adjacent task ids. Remote clients authenticate with revocable Bearer tokens. A token has anactor and capability set, no implicit expiry, is shown once, and only itsSHA-256 digest is persisted. Existing Basic and browser session auth remain. Consequences The browser, CLI, MCP and direct API calls exercise the same validation,activity and GitHub synchronization path. A GitHub outage degrades only thebinding. Tokens can be revoked without rotating the panel password. Sparseranks avoid rewriting a column on ordinary moves, while the rare rebalance isbounded to one project/status column.","headings":[{"id":"0033-tasks-are-local-issues-with-sparse-board-ranks-and-api-credentials","text":"0033. Tasks are local issues with sparse board ranks and API credentials","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":12},{"id":"consequences","text":"Consequences","level":2,"line":33}],"kind":"markdown"},{"slug":"adr/0034-child-process-output","title":"0034. A child process is never silent for long","description":"0034. A child process is never silent for long.","source":"docs/development/adr/0034-child-process-output.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0034-child-process-output","markdown":"# 0034. A child process is never silent for long\n\n**Status:** Accepted\n\nThe CLI invokes child processes through one adapter. Interactive builds and\npulls stream output. Captured commands report elapsed time after ten seconds\nand periodically afterward. `--verbose` streams all child output, `--quiet`\nsuppresses progress, and `--json` keeps stdout reserved for machine data.\n\nCommands are executable-plus-argument-array calls with no shell expansion.\nLong builds are not killed by an arbitrary timeout; cancellation is left to\nthe caller.\n","text":"0034. A child process is never silent for long Status: Accepted The CLI invokes child processes through one adapter. Interactive builds andpulls stream output. Captured commands report elapsed time after ten secondsand periodically afterward. --verbose streams all child output, --quietsuppresses progress, and --json keeps stdout reserved for machine data. Commands are executable-plus-argument-array calls with no shell expansion.Long builds are not killed by an arbitrary timeout; cancellation is left tothe caller.","headings":[{"id":"0034-a-child-process-is-never-silent-for-long","text":"0034. A child process is never silent for long","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0035-authentication-lives-in-the-panel","title":"0035. The panel authenticates its own requests","description":"0035. The panel authenticates its own requests.","source":"docs/development/adr/0035-authentication-lives-in-the-panel.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0035-authentication-lives-in-the-panel","markdown":"# 0035. The panel authenticates its own requests\n\n**Status:** Accepted\n\n`PORTTA_AUTH_MODE=disabled` is valid only for a loopback panel and represents\nthe local operator. `required` uses panel-owned accounts, sessions, optional\nsecond factors and personal API tokens. The first owner is created once through\n`/setup` or `portta auth bootstrap`; later accounts are administered by an\nauthorized user.\n\nEvery protected API route declares a permission. Project-scoped operations are\nchecked again after the resource is resolved. Read-only mode removes mutations\nfrom every principal. `PORTTA_AUTH_SECRET` signs sessions and tokens and is\nnever returned by the configuration API.\n\nForwardAuth remains a separate boundary for protected application hosts and\nshares; it does not authenticate the panel.\n","text":"0035. The panel authenticates its own requests Status: Accepted PORTTA_AUTH_MODE=disabled is valid only for a loopback panel and representsthe local operator. required uses panel-owned accounts, sessions, optionalsecond factors and personal API tokens. The first owner is created once through/setup or portta auth bootstrap; later accounts are administered by anauthorized user. Every protected API route declares a permission. Project-scoped operations arechecked again after the resource is resolved. Read-only mode removes mutationsfrom every principal. PORTTA_AUTH_SECRET signs sessions and tokens and isnever returned by the configuration API. ForwardAuth remains a separate boundary for protected application hosts andshares; it does not authenticate the panel.","headings":[{"id":"0035-the-panel-authenticates-its-own-requests","text":"0035. The panel authenticates its own requests","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0036-next-app-router-and-the-custom-server","title":"0036. The panel is a Next application on a server of its own","description":"0036. The panel is a Next application on a server of its own.","source":"docs/development/adr/0036-next-app-router-and-the-custom-server.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0036-next-app-router-and-the-custom-server","markdown":"# 0036. The panel is a Next application on a server of its own\n\n**Status:** Accepted, amends [0011](0011-bounded-traefik-write-surface.md)\n\n## Context\n\nThe panel was a Vite single-page application talking to a Hono API on another\nport, with a development proxy in between. That worked while nobody had to sign\nin, because there was nothing an origin decided.\n\nSigning people in changes it. A session cookie belongs to one origin. Two ports\nmeans either two origins and a cookie that does not travel, or a proxy in front\nof the panel whose only job is to make the two look like one — a third moving\npart, in a product whose whole promise is a small permanent footprint.\n\nThe pages had their own problem. Every screen was rendered empty and then\nfilled by a fetch, which meant every screen had a loading state, a race, and a\nmoment where a viewer saw controls they were about to be told they could not\nuse. Deciding what somebody may see *before* the HTML is written removes all\nthree.\n\n## Decision\n\nThe panel is a Next 16 application on the App Router, served by a Node HTTP\nserver the panel owns. One process, one port, four dispatches:\n\n| Path | Handled by |\n|---|---|\n| `/api/*` | The Hono app: the whole API, and the event stream |\n| `/ws/*` | The authorised upgrade handler ([ADR 0035](0035-authentication-lives-in-the-panel.md)) |\n| `/_next/*` (development) | Next's own upgrade handler, for HMR |\n| everything else | Next's request handler |\n\n`apps/web/server/compose.ts` is that dispatcher and takes its handlers as\narguments, so a test drives the whole composition with a fake Next and no\nDocker, no database and no port.\n\nPages are Server Components. Each one resolves its own principal and reads\nthrough `services/reads.ts` — the same scope rules the API applies, called\ndirectly rather than over HTTP — and hands the result to a client component as\n`initialData`. React's `cache()` deduplicates a layout and its page asking for\nthe same Project. A page a role does not have calls `notFound()`, because a\npage that is not part of somebody's panel is not a door with a sign on it.\n\n`(panel)/layout.tsx` is the one entrance. There is no middleware: a `proxy.ts`\nwould run on every asset, could not reach the database, and would answer a\nquestion the layout already answers once per render.\n\n## Consequences\n\nThe Vite SPA, its router, its `useDocumentTitle` hook and the development proxy\nare gone. Titles come from each route's `generateMetadata`; navigation is the\nApp Router's; the documentation at `/docs` is prerendered from the repository's\nown Markdown at build time.\n\n`portta-server` stays a library rather than a service: it exports `createApp`,\nthe read functions and the realtime pieces, and `apps/web/server/main.ts`\ncomposes them. Nothing in it imports Next, so the API can still be reasoned\nabout — and tested — without a bundler.\n\nTwo costs, taken deliberately. The panel now needs a build step that produces\n`.next`, which the image does and a developer does with `portta web dev`. And\n`portta-server`, `portta-auth-core`, `portta-db` and their native dependencies\nare `serverExternalPackages`: they open sockets and resolve paths from\n`import.meta.url`, which a bundler cannot follow. A client component that\nimports one of them fails at build time, which is the correct direction to\nfail.\n","text":"0036. The panel is a Next application on a server of its own Status: Accepted, amends 0011 Context The panel was a Vite single-page application talking to a Hono API on anotherport, with a development proxy in between. That worked while nobody had to signin, because there was nothing an origin decided. Signing people in changes it. A session cookie belongs to one origin. Two portsmeans either two origins and a cookie that does not travel, or a proxy in frontof the panel whose only job is to make the two look like one — a third movingpart, in a product whose whole promise is a small permanent footprint. The pages had their own problem. Every screen was rendered empty and thenfilled by a fetch, which meant every screen had a loading state, a race, and amoment where a viewer saw controls they were about to be told they could notuse. Deciding what somebody may see before the HTML is written removes allthree. Decision The panel is a Next 16 application on the App Router, served by a Node HTTPserver the panel owns. One process, one port, four dispatches: Path Handled by /api/* The Hono app: the whole API, and the event stream /ws/* The authorised upgrade handler (ADR 0035) /_next/* (development) Next's own upgrade handler, for HMR everything else Next's request handler apps/web/server/compose.ts is that dispatcher and takes its handlers asarguments, so a test drives the whole composition with a fake Next and noDocker, no database and no port. Pages are Server Components. Each one resolves its own principal and readsthrough services/reads.ts — the same scope rules the API applies, calleddirectly rather than over HTTP — and hands the result to a client component asinitialData. React's cache() deduplicates a layout and its page asking forthe same Project. A page a role does not have calls notFound(), because apage that is not part of somebody's panel is not a door with a sign on it. (panel)/layout.tsx is the one entrance. There is no middleware: a proxy.tswould run on every asset, could not reach the database, and would answer aquestion the layout already answers once per render. Consequences The Vite SPA, its router, its useDocumentTitle hook and the development proxyare gone. Titles come from each route's generateMetadata; navigation is theApp Router's; the documentation at /docs is prerendered from the repository'sown Markdown at build time. portta-server stays a library rather than a service: it exports createApp,the read functions and the realtime pieces, and apps/web/server/main.tscomposes them. Nothing in it imports Next, so the API can still be reasonedabout — and tested — without a bundler. Two costs, taken deliberately. The panel now needs a build step that produces.next, which the image does and a developer does with portta web dev. Andportta-server, portta-auth-core, portta-db and their native dependenciesare serverExternalPackages: they open sockets and resolve paths fromimport.meta.url, which a bundler cannot follow. A client component thatimports one of them fails at build time, which is the correct direction tofail.","headings":[{"id":"0036-the-panel-is-a-next-application-on-a-server-of-its-own","text":"0036. The panel is a Next application on a server of its own","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":21},{"id":"consequences","text":"Consequences","level":2,"line":48}],"kind":"markdown"},{"slug":"adr/0037-drizzle-and-a-required-database","title":"0037. The panel requires PostgreSQL and uses Drizzle","description":"0037. Drizzle, and a database the panel refuses to start without.","source":"docs/development/adr/0037-drizzle-and-a-required-database.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0037-drizzle-and-a-required-database","markdown":"# 0037. The panel requires PostgreSQL and uses Drizzle\n\n**Status:** Accepted\n\nThe schema lives in `packages/db/src/schema`. Drizzle generates one current\nbaseline migration for fresh installations, and `db:check` verifies that SQL\nand metadata match the schema. The panel runs migrations under a database lock\nbefore opening HTTP and exits when PostgreSQL is unavailable.\n\nManaged mode uses a private network, a named volume and no published database\nport. External mode requires an explicit connection URL and omits the managed\ndatabase overlay.\n","text":"0037. The panel requires PostgreSQL and uses Drizzle Status: Accepted The schema lives in packages/db/src/schema. Drizzle generates one currentbaseline migration for fresh installations, and db:check verifies that SQLand metadata match the schema. The panel runs migrations under a database lockbefore opening HTTP and exits when PostgreSQL is unavailable. Managed mode uses a private network, a named volume and no published databaseport. External mode requires an explicit connection URL and omits the manageddatabase overlay.","headings":[{"id":"0037-the-panel-requires-postgresql-and-uses-drizzle","text":"0037. The panel requires PostgreSQL and uses Drizzle","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0038-roles-and-project-access","title":"0038. Four roles, and access by Project","description":"0038. Four roles, and access by Project.","source":"docs/development/adr/0038-roles-and-project-access.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0038-roles-and-project-access","markdown":"# 0038. Four roles, and access by Project\n\n**Status:** Accepted, extends [0035](0035-authentication-lives-in-the-panel.md)\n\n## Context\n\nOnce the panel has accounts, \"signed in\" stops being an answer. A panel that\ncan start, stop and remove every container on a host, register repositories,\nopen bridges and rewrite the gateway's configuration cannot give the same\npowers to everybody who has a password.\n\nTwo questions have to be answered separately, and conflating them is the\nmistake to avoid: **what may this person do**, and **where**. A developer may\nrestart a service; that does not mean they may restart *your* service.\n\n## Decision\n\n**A flat vocabulary.** Permissions are `resource:action` — `task:write`,\n`environment:destroy`, `settings:manage` — generated from one statement table\nin `packages/auth/src/access-control.ts`. Adding an action is one line there,\nnot five in five files. Better Auth's admin plugin authorises against exactly\nthis vocabulary, so its `user` and `session` resources are spread in rather\nthan restated.\n\n**Four roles, and no more.** `owner`, `admin`, `developer`, `viewer`. Roles are\nnot editable: a product whose whole point is a small permanent footprint does\nnot need a role editor, and a team that needs one has outgrown a development\nhost. Impersonation is offered by the plugin and held by nobody.\n\n**Every route declares its permission.** `documentRoute({ permission })`\nregisters it in the OpenAPI document as `x-portta-permission` and returns the\nmiddleware that checks it. A test walks the document and fails if an operation\noutside the three public routes declares none — which is what makes \"did we\nforget one\" a build failure rather than an audit.\n\n**Scope is checked after the resource is read.** The permission is checked at\nthe door, without a Project; the Project is checked in the handler, once the\nresource has been read and it is known which one it belongs to. Which Project a\nthing is in comes from where it actually lives: a task from its row, an\nenvironment from the Project that adopted it, a bridge from the environment it\ntargets. An environment nobody adopted belongs to nobody and is visible to\n`owner` and `admin` alone.\n\n**Listings filter; named resources refuse.** Asking for the Projects returns\nyours, not a 403 about somebody else's. Asking for one by name that you are not\nin is a 403. The Overview sums only what you can see and the event stream\ndelivers only events about it.\n\n**The owner is a person, not a permission.** Some rules cannot be expressed as\na permission at all, and they live as `refusalFor*` helpers beside the service\nthat applies them: nobody changes their own role, nobody removes their own\naccount, only the owner acts on the owner, ownership is transferred rather than\nassigned, and the last owner cannot be removed.\n\n## Consequences\n\n`401` and `403` mean different things and are tested as such: no credential is\n401, a credential without the permission or the Project is 403.\n\nLosing a membership closes the door on the next request rather than the next\nsign-in, because the scope is resolved per request and the event stream re-reads\nit while it is open.\n\nA `viewer` still holds `token:*` for tokens of their own, which is the one thing\nevery role can do: a token never exceeds its owner, so a viewer's token is a\nviewer.\n","text":"0038. Four roles, and access by Project Status: Accepted, extends 0035 Context Once the panel has accounts, \"signed in\" stops being an answer. A panel thatcan start, stop and remove every container on a host, register repositories,open bridges and rewrite the gateway's configuration cannot give the samepowers to everybody who has a password. Two questions have to be answered separately, and conflating them is themistake to avoid: what may this person do, and where. A developer mayrestart a service; that does not mean they may restart your service. Decision A flat vocabulary. Permissions are resource:action — task:write,environment:destroy, settings:manage — generated from one statement tablein packages/auth/src/access-control.ts. Adding an action is one line there,not five in five files. Better Auth's admin plugin authorises against exactlythis vocabulary, so its user and session resources are spread in ratherthan restated. Four roles, and no more. owner, admin, developer, viewer. Roles arenot editable: a product whose whole point is a small permanent footprint doesnot need a role editor, and a team that needs one has outgrown a developmenthost. Impersonation is offered by the plugin and held by nobody. Every route declares its permission. documentRoute({ permission })registers it in the OpenAPI document as x-portta-permission and returns themiddleware that checks it. A test walks the document and fails if an operationoutside the three public routes declares none — which is what makes \"did weforget one\" a build failure rather than an audit. Scope is checked after the resource is read. The permission is checked atthe door, without a Project; the Project is checked in the handler, once theresource has been read and it is known which one it belongs to. Which Project athing is in comes from where it actually lives: a task from its row, anenvironment from the Project that adopted it, a bridge from the environment ittargets. An environment nobody adopted belongs to nobody and is visible toowner and admin alone. Listings filter; named resources refuse. Asking for the Projects returnsyours, not a 403 about somebody else's. Asking for one by name that you are notin is a 403. The Overview sums only what you can see and the event streamdelivers only events about it. The owner is a person, not a permission. Some rules cannot be expressed asa permission at all, and they live as refusalFor* helpers beside the servicethat applies them: nobody changes their own role, nobody removes their ownaccount, only the owner acts on the owner, ownership is transferred rather thanassigned, and the last owner cannot be removed. Consequences 401 and 403 mean different things and are tested as such: no credential is401, a credential without the permission or the Project is 403. Losing a membership closes the door on the next request rather than the nextsign-in, because the scope is resolved per request and the event stream re-readsit while it is open. A viewer still holds token:* for tokens of their own, which is the one thingevery role can do: a token never exceeds its owner, so a viewer's token is aviewer.","headings":[{"id":"0038-four-roles-and-access-by-project","text":"0038. Four roles, and access by Project","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":15},{"id":"consequences","text":"Consequences","level":2,"line":54}],"kind":"markdown"},{"slug":"adr/0039-personal-api-tokens","title":"0039. A token belongs to a person, and never exceeds them","description":"0039. A token belongs to a person, and never exceeds them.","source":"docs/development/adr/0039-personal-api-tokens.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0039-personal-api-tokens","markdown":"# 0039. A token belongs to a person, and never exceeds them\n\n**Status:** Accepted, extends [0035](0035-authentication-lives-in-the-panel.md), supersedes the panel tokens in [0033](0033-tasks-are-local-issues.md)\n\n## Context\n\nA browser has a session. A CLI on a laptop, an agent in a terminal and a job in\nCI do not, and giving them one would mean a headless sign-in — which is a\npassword in a script.\n\nThe panel had API tokens before: rows in a JSON store beside the ForwardAuth\nprotections, carrying capabilities of their own. That made a token an\nindependent grant, and an independent grant is one nobody remembers to review.\n\n## Decision\n\nA Portta token is a `ptt_`-prefixed Bearer credential **belonging to a user**,\nissued through Better Auth's api-key plugin into the panel's own database.\n\n**It never exceeds its owner.** What a token holds is the intersection of its\nown scopes and its owner's role, computed on every request. Lowering somebody's\nrole lowers every token they made without touching the tokens; banning them\nstops all of them at once; removing them takes the rows with the account.\n\n**Its default is the smallest thing that is useful.** A token declares whether\nit is used by a person or by an agent. A person's holds their whole role, as if\nthey were at the keyboard. An agent's holds what agents hold on this panel —\nthe `agentPermissions` setting, defaulting to a developer minus the three\nthings that change how the panel itself behaves. Asking for scopes the owner\ndoes not hold is a 400 that names exactly which ones did not fit.\n\n**The secret is shown once.** The panel stores a hash and the first characters,\nenough to recognise a token in a listing and useless to anybody else. The\nprefix is deliberate: `ptt_` is a shape a secret scanner can be taught.\n\n**Ownership decides who may act on one.** Your tokens are yours to make and\nrevoke. Somebody else's needs `user:list` to see and `user:update` to revoke,\nbecause revoking a colleague's credential is an administrative act — and the\none that makes a lost laptop somebody else's problem to solve.\n\n**Bearer, and nothing else.** `x-api-key` is not accepted, whatever the plugin\nwould do with it: one way in is one way to reason about.\n\n## Consequences\n\nRevoking is immediate on the next request that carries the token, and the row\nstays so a listing can still say what was revoked. Housekeeping disables a\ntoken that expired more than thirty days ago and deletes one revoked more than\nninety days ago.\n\nThe token store in `state/auth/protections.json` is gone; that file now carries\nonly what it is for — the ForwardAuth protections for project hostnames and\nshares, which have nothing to do with who may administer the panel.\n\n`portta auth login` checks a token against the panel before saving it, so a\ntypo fails at the terminal rather than on the next command, and the store is\none entry per panel URL: a laptop panel and a server panel are not the same\ncredential.\n","text":"0039. A token belongs to a person, and never exceeds them Status: Accepted, extends 0035, supersedes the panel tokens in 0033 Context A browser has a session. A CLI on a laptop, an agent in a terminal and a job inCI do not, and giving them one would mean a headless sign-in — which is apassword in a script. The panel had API tokens before: rows in a JSON store beside the ForwardAuthprotections, carrying capabilities of their own. That made a token anindependent grant, and an independent grant is one nobody remembers to review. Decision A Portta token is a ptt_-prefixed Bearer credential belonging to a user,issued through Better Auth's api-key plugin into the panel's own database. It never exceeds its owner. What a token holds is the intersection of itsown scopes and its owner's role, computed on every request. Lowering somebody'srole lowers every token they made without touching the tokens; banning themstops all of them at once; removing them takes the rows with the account. Its default is the smallest thing that is useful. A token declares whetherit is used by a person or by an agent. A person's holds their whole role, as ifthey were at the keyboard. An agent's holds what agents hold on this panel —the agentPermissions setting, defaulting to a developer minus the threethings that change how the panel itself behaves. Asking for scopes the ownerdoes not hold is a 400 that names exactly which ones did not fit. The secret is shown once. The panel stores a hash and the first characters,enough to recognise a token in a listing and useless to anybody else. Theprefix is deliberate: ptt_ is a shape a secret scanner can be taught. Ownership decides who may act on one. Your tokens are yours to make andrevoke. Somebody else's needs user:list to see and user:update to revoke,because revoking a colleague's credential is an administrative act — and theone that makes a lost laptop somebody else's problem to solve. Bearer, and nothing else. x-api-key is not accepted, whatever the pluginwould do with it: one way in is one way to reason about. Consequences Revoking is immediate on the next request that carries the token, and the rowstays so a listing can still say what was revoked. Housekeeping disables atoken that expired more than thirty days ago and deletes one revoked more thanninety days ago. The token store in state/auth/protections.json is gone; that file now carriesonly what it is for — the ForwardAuth protections for project hostnames andshares, which have nothing to do with who may administer the panel. portta auth login checks a token against the panel before saving it, so atypo fails at the terminal rather than on the next command, and the store isone entry per panel URL: a laptop panel and a server panel are not the samecredential.","headings":[{"id":"0039-a-token-belongs-to-a-person-and-never-exceeds-them","text":"0039. A token belongs to a person, and never exceeds them","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":14},{"id":"consequences","text":"Consequences","level":2,"line":43}],"kind":"markdown"},{"slug":"adr/0040-installation-environment-contract","title":"0040. The installation environment is the configuration contract","description":"0040 — The installation environment is the configuration contract.","source":"docs/development/adr/0040-installation-environment-contract.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0040-installation-environment-contract","markdown":"# 0040. The installation environment is the configuration contract\n\n**Status:** Accepted\n\n`.env.example` defines the supported installation keys and structure; `.env`\nholds concrete values. The TypeScript configuration editor parses values\nwithout executing them, rejects duplicates, preserves comments and unknown\noperator extensions, generates missing secrets once and writes with mode 0600.\n\nPersisted installation values win over inherited shell variables. Explicit CLI\nchoices are written before Compose is resolved. Host and panel writers share a\nfilesystem lock and keep the file inode stable for bind mounts.\n\nManaged database values derive one internal connection contract. External mode\nrequires `PORTTA_RUNTIME_DATABASE_URL`. Derived URLs and secrets are not\npersisted under additional names.\n","text":"0040. The installation environment is the configuration contract Status: Accepted .env.example defines the supported installation keys and structure; .envholds concrete values. The TypeScript configuration editor parses valueswithout executing them, rejects duplicates, preserves comments and unknownoperator extensions, generates missing secrets once and writes with mode 0600. Persisted installation values win over inherited shell variables. Explicit CLIchoices are written before Compose is resolved. Host and panel writers share afilesystem lock and keep the file inode stable for bind mounts. Managed database values derive one internal connection contract. External moderequires PORTTA_RUNTIME_DATABASE_URL. Derived URLs and secrets are notpersisted under additional names.","headings":[{"id":"0040-the-installation-environment-is-the-configuration-contract","text":"0040. The installation environment is the configuration contract","level":1,"line":0}],"kind":"markdown"},{"slug":"adr/0041-cli-interaction-dependencies","title":"0041 — Keep the CLI machine-first and dependency-light","description":"0041. Keep the CLI machine-first and dependency-light.","source":"docs/development/adr/0041-cli-interaction-dependencies.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0041-cli-interaction-dependencies","markdown":"# 0041 — Keep the CLI machine-first and dependency-light\n\nStatus: accepted (2026-09-06).\n\n## Context\n\nPortta's CLI is both an operator interface and an automation protocol. Its\nstdout contract under `--json`, refusal to prompt without a TTY, default-No\nconfirmation, exit codes, aliases and nested Commander tree are compatibility\nsurface. A nicer prompt or completion package is useful only if it preserves\nthat surface and earns its installation cost.\n\nIssue #47 evaluated `@clack/prompts`, `@clack/core`, `@bomb.sh/tab` and\n`@bomb.sh/args` against the published packages available on 2026-09-06. The\nspike used isolated production installs built from the CLI's current direct\ndependencies, so packages hoisted by other workspaces did not distort the\nmeasurement.\n\n| Candidate | Version | Isolated production-tree delta | Decision |\n|---|---:|---:|---|\n| `@clack/prompts` | 1.7.0 | +6 packages, +316 KiB | Decline |\n| `@clack/core` | 1.4.3 | +5 packages, +188 KiB | Decline as a direct dependency |\n| `@bomb.sh/tab` | 0.0.22 | +1 package, +112 KiB | Defer |\n| `@bomb.sh/args` | 0.3.1 | +1 package, +40 KiB | Decline |\n\nThe baseline contained 114 packages and occupied 28,812 KiB. Published\nunpacked sizes were also inspected, but are not used as the install delta.\n\n## Decision\n\nKeep Commander, the current readline confirmation and the current output\nhelpers. Add none of the four evaluated packages.\n\n`@clack/prompts` can write to stderr and cancellation can be mapped safely, but\nits confirmation defaults to Yes unless explicitly changed. Portta's existing\n13-line helper already enforces default No and refuses non-TTY input. Six more\nproduction packages would improve presentation without adding a missing\ncapability. `@clack/core` has no independent use once that decision is made.\n\n`@bomb.sh/args` is a flat flag parser. It does not replace Portta's nested\ncommand tree, inherited global options, hooks, help behavior, aliases and\nvariadic arguments. Commander remains the parser.\n\nShell completion remains desirable, but `@bomb.sh/tab` is not adopted at\nversion 0.0.22. Its Commander adapter walks visible commands by `name()` and\ndoes not register `aliases()`. It would therefore fail Portta's compatibility\naliases. Values such as the optional profile for `up [profile]` also need\nexplicit Commander choices before they can be completed. A partial completion\nfeature that silently omits supported syntax is worse than no advertised\ncompletion.\n\nNo implementation issue is opened from this investigation because no candidate\nis marked for adoption. Reconsider completion when an adapter demonstrably\nhandles aliases, nested commands, optional and variadic arguments, dynamic\ncallbacks and Bash, Zsh, Fish and PowerShell. Any later implementation must be\na separate issue and preserve the machine-output tests before it is merged.\n\n## Preserved contracts\n\n- Requested data is the only content written to stdout under `--json`.\n- Prompts and narration use stderr and never appear without a TTY.\n- Confirmation remains No by default; `--yes` is the explicit automation path.\n- `--quiet` suppresses narration, not errors or requested data.\n- Existing command names, aliases, help and exit codes remain Commander-owned.\n- The CLI remains independent of browser UI packages.\n\n## Consequences\n\nThere is no new runtime dependency, completion command or migration burden.\nThe CLI retains its current visual style. A future completion proposal starts\nfrom explicit contract fixtures rather than treating the command tree as an\nimplementation detail.\n\nSee [CLI reference](../../product/reference/cli.md) and\n[child-process output](0034-child-process-output.md).\n","text":"0041 — Keep the CLI machine-first and dependency-light Status: accepted (2026-09-06). Context Portta's CLI is both an operator interface and an automation protocol. Itsstdout contract under --json, refusal to prompt without a TTY, default-Noconfirmation, exit codes, aliases and nested Commander tree are compatibilitysurface. A nicer prompt or completion package is useful only if it preservesthat surface and earns its installation cost. Issue #47 evaluated @clack/prompts, @clack/core, @bomb.sh/tab and@bomb.sh/args against the published packages available on 2026-09-06. Thespike used isolated production installs built from the CLI's current directdependencies, so packages hoisted by other workspaces did not distort themeasurement. Candidate Version Isolated production-tree delta Decision @clack/prompts 1.7.0 +6 packages, +316 KiB Decline @clack/core 1.4.3 +5 packages, +188 KiB Decline as a direct dependency @bomb.sh/tab 0.0.22 +1 package, +112 KiB Defer @bomb.sh/args 0.3.1 +1 package, +40 KiB Decline The baseline contained 114 packages and occupied 28,812 KiB. Publishedunpacked sizes were also inspected, but are not used as the install delta. Decision Keep Commander, the current readline confirmation and the current outputhelpers. Add none of the four evaluated packages. @clack/prompts can write to stderr and cancellation can be mapped safely, butits confirmation defaults to Yes unless explicitly changed. Portta's existing13-line helper already enforces default No and refuses non-TTY input. Six moreproduction packages would improve presentation without adding a missingcapability. @clack/core has no independent use once that decision is made. @bomb.sh/args is a flat flag parser. It does not replace Portta's nestedcommand tree, inherited global options, hooks, help behavior, aliases andvariadic arguments. Commander remains the parser. Shell completion remains desirable, but @bomb.sh/tab is not adopted atversion 0.0.22. Its Commander adapter walks visible commands by name() anddoes not register aliases(). It would therefore fail Portta's compatibilityaliases. Values such as the optional profile for up [profile] also needexplicit Commander choices before they can be completed. A partial completionfeature that silently omits supported syntax is worse than no advertisedcompletion. No implementation issue is opened from this investigation because no candidateis marked for adoption. Reconsider completion when an adapter demonstrablyhandles aliases, nested commands, optional and variadic arguments, dynamiccallbacks and Bash, Zsh, Fish and PowerShell. Any later implementation must bea separate issue and preserve the machine-output tests before it is merged. Preserved contracts Requested data is the only content written to stdout under --json. Prompts and narration use stderr and never appear without a TTY. Confirmation remains No by default; --yes is the explicit automation path. --quiet suppresses narration, not errors or requested data. Existing command names, aliases, help and exit codes remain Commander-owned. The CLI remains independent of browser UI packages. Consequences There is no new runtime dependency, completion command or migration burden.The CLI retains its current visual style. A future completion proposal startsfrom explicit contract fixtures rather than treating the command tree as animplementation detail. See CLI reference andchild-process output.","headings":[{"id":"0041--keep-the-cli-machine-first-and-dependency-light","text":"0041 — Keep the CLI machine-first and dependency-light","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":28},{"id":"preserved-contracts","text":"Preserved contracts","level":2,"line":57},{"id":"consequences","text":"Consequences","level":2,"line":66}],"kind":"markdown"},{"slug":"adr/0042-portta-owned-ssh-keys","title":"0042 — Portta-owned SSH keys use a narrow panel boundary","description":"0042. Portta-owned SSH keys use a narrow panel boundary.","source":"docs/development/adr/0042-portta-owned-ssh-keys.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0042-portta-owned-ssh-keys","markdown":"# 0042 — Portta-owned SSH keys use a narrow panel boundary\n\n**Status:** Accepted\n\n## Context\n\nRepository and remote-host workflows need SSH credentials. The existing host\nrunner accepts only typed Compose lifecycle requests; turning it into a general\ncommand or secret runner would enlarge a deliberately small trust boundary.\nKeeping private material in PostgreSQL would also make it easier for an API or\nbackup to expose it accidentally.\n\n## Decision\n\nThe panel owns a dedicated `state/ssh` directory, mounted read-write and scoped\nto one Portta installation. The directory is `0700`; private files are created\nas `0600` and public files as `0644`. PostgreSQL stores only name, description,\nalgorithm, fingerprint, public key, origin and creation time. There is no\nprivate-key column.\n\nThe panel image includes the OpenSSH client and exposes typed operations only:\ngenerate ED25519 or RSA-4096, derive a public key from an imported unencrypted\nprivate key, remove a key, and test it against a fixed forge catalogue. Each\noperation builds a fixed argv and never invokes a shell. There is no arbitrary\nhostname or command surface. A forge test writes that forge's published host\nkeys and uses `StrictHostKeyChecking=yes`, so the first greeting cannot pin\nan unexpected key.\n\nThe API never returns private material. Audit rows identify the key by name and\nfingerprint only. `ssh:read` is granted to every role; `ssh:manage` only to\nowner and administrator. The panel container runs with `PORTTA_WEB_USER`, so\nnew files have the installation owner's uid from creation rather than being\nrepaired afterward.\n\n## Consequences\n\nThe panel is now a credential-owning process and must keep its dedicated mount\nsmall. Backing up `state/ssh` is an explicit operator decision; Portta does not\nencrypt these files at rest. Passphrase-protected imports are refused because\nthe non-interactive service cannot safely prompt. A future agent-forwarding or\nhardware-key feature needs a separate decision rather than weakening this\nboundary.\n","text":"0042 — Portta-owned SSH keys use a narrow panel boundary Status: Accepted Context Repository and remote-host workflows need SSH credentials. The existing hostrunner accepts only typed Compose lifecycle requests; turning it into a generalcommand or secret runner would enlarge a deliberately small trust boundary.Keeping private material in PostgreSQL would also make it easier for an API orbackup to expose it accidentally. Decision The panel owns a dedicated state/ssh directory, mounted read-write and scopedto one Portta installation. The directory is 0700; private files are createdas 0600 and public files as 0644. PostgreSQL stores only name, description,algorithm, fingerprint, public key, origin and creation time. There is noprivate-key column. The panel image includes the OpenSSH client and exposes typed operations only:generate ED25519 or RSA-4096, derive a public key from an imported unencryptedprivate key, remove a key, and test it against a fixed forge catalogue. Eachoperation builds a fixed argv and never invokes a shell. There is no arbitraryhostname or command surface. A forge test writes that forge's published hostkeys and uses StrictHostKeyChecking=yes, so the first greeting cannot pinan unexpected key. The API never returns private material. Audit rows identify the key by name andfingerprint only. ssh:read is granted to every role; ssh:manage only toowner and administrator. The panel container runs with PORTTA_WEB_USER, sonew files have the installation owner's uid from creation rather than beingrepaired afterward. Consequences The panel is now a credential-owning process and must keep its dedicated mountsmall. Backing up state/ssh is an explicit operator decision; Portta does notencrypt these files at rest. Passphrase-protected imports are refused becausethe non-interactive service cannot safely prompt. A future agent-forwarding orhardware-key feature needs a separate decision rather than weakening thisboundary.","headings":[{"id":"0042--portta-owned-ssh-keys-use-a-narrow-panel-boundary","text":"0042 — Portta-owned SSH keys use a narrow panel boundary","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":12},{"id":"consequences","text":"Consequences","level":2,"line":34}],"kind":"markdown"},{"slug":"adr/0043-container-console-over-docker-exec","title":"0043 — Container consoles use Docker exec through the panel proxy","description":"0043. Container consoles use Docker exec through the panel proxy.","source":"docs/development/adr/0043-container-console-over-docker-exec.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0043-container-console-over-docker-exec","markdown":"# 0043 — Container consoles use Docker exec through the panel proxy\n\n**Status:** Accepted\n\n## Context\n\nThe panel could inspect and operate a project container but could not complete\nan interactive diagnosis. Docker exec is more privileged than a lifecycle\noperation: it can read the process environment, mounted files and application\nconfiguration. The panel proxy and the application allowlist therefore denied\nit independently.\n\n## Spike result\n\nAn isolated spike used `tecnativa/docker-socket-proxy:v0.5.0`, an Alpine target\nand a Node 24 client on a private Docker network. With `CONTAINERS=1`, `EXEC=1`\nand `POST=1`, the proxy returned `201` for exec creation, forwarded the HTTP\nhijack with `101`, carried an interactive marker in both directions, returned\n`200` for resize and exposed `Running` through inspect.\n\nDestroying the hijacked socket did **not** stop the exec. A second exec could\nnot kill the `Pid` reported by inspect because it is in the daemon's host PID\nnamespace. Keeping the hijack open, sending terminal `Ctrl-C` followed by\n`exit`, and then inspecting did stop it (`Running: false`). All spike containers\nand its network were removed after the measurement.\n\n## Decision\n\nThe panel proxy enables the `EXEC` category. The application allowlist admits\nexactly four paths: create, attach/start, resize and inspect. Traefik's proxy is\nunchanged. The client retains its handwritten, typed API; there is no generic\nDocker request.\n\n`container:console` is a separate permission held by owner and administrator,\nnot developer, viewer or the default local agent. A console is resolved from an\nenvironment and service already visible to the panel, must be running and may\nnot be a Portta-owned component. The server chooses `/bin/bash` then `/bin/sh`,\nnever accepts a user, command or privileged flag, and reports a shell-less\nimage explicitly.\n\nThe browser and Docker streams remain separate. Browser binary frames carry TTY\nbytes; JSON text frames carry bounded resize messages. The session idles out\nafter 15 minutes and ends after two hours. On every close path the server keeps\nthe Docker stream, sends interrupt plus `exit`, verifies inspect, tries terminal\nquit plus `exit` if needed, then releases the stream. The closing audit entry\nrecords whether Docker confirmed teardown. Keystrokes and terminal output are\nnever audited.\n\nThe terminal emulator is dynamically imported only after an authorised\noperator opens the console.\n\n## Consequences\n\nThe panel becomes a stronger target: an administrator may read any secret the\nselected project container can read. This is why the grant is narrower than\n`container:operate`, the action is hidden without permission and public panel\naccess still requires authentication.\n\nDocker has no exec-kill endpoint. The verified TTY teardown works for the fixed\ninteractive shells, while a hostile program that changes terminal signal\nhandling can delay exit. A false `teardownConfirmed` audit value is an explicit\noperational finding; silently claiming the session ended is not acceptable.\n","text":"0043 — Container consoles use Docker exec through the panel proxy Status: Accepted Context The panel could inspect and operate a project container but could not completean interactive diagnosis. Docker exec is more privileged than a lifecycleoperation: it can read the process environment, mounted files and applicationconfiguration. The panel proxy and the application allowlist therefore deniedit independently. Spike result An isolated spike used tecnativa/docker-socket-proxy:v0.5.0, an Alpine targetand a Node 24 client on a private Docker network. With CONTAINERS=1, EXEC=1and POST=1, the proxy returned 201 for exec creation, forwarded the HTTPhijack with 101, carried an interactive marker in both directions, returned200 for resize and exposed Running through inspect. Destroying the hijacked socket did not stop the exec. A second exec couldnot kill the Pid reported by inspect because it is in the daemon's host PIDnamespace. Keeping the hijack open, sending terminal Ctrl-C followed byexit, and then inspecting did stop it (Running: false). All spike containersand its network were removed after the measurement. Decision The panel proxy enables the EXEC category. The application allowlist admitsexactly four paths: create, attach/start, resize and inspect. Traefik's proxy isunchanged. The client retains its handwritten, typed API; there is no genericDocker request. container:console is a separate permission held by owner and administrator,not developer, viewer or the default local agent. A console is resolved from anenvironment and service already visible to the panel, must be running and maynot be a Portta-owned component. The server chooses /bin/bash then /bin/sh,never accepts a user, command or privileged flag, and reports a shell-lessimage explicitly. The browser and Docker streams remain separate. Browser binary frames carry TTYbytes; JSON text frames carry bounded resize messages. The session idles outafter 15 minutes and ends after two hours. On every close path the server keepsthe Docker stream, sends interrupt plus exit, verifies inspect, tries terminalquit plus exit if needed, then releases the stream. The closing audit entryrecords whether Docker confirmed teardown. Keystrokes and terminal output arenever audited. The terminal emulator is dynamically imported only after an authorisedoperator opens the console. Consequences The panel becomes a stronger target: an administrator may read any secret theselected project container can read. This is why the grant is narrower thancontainer:operate, the action is hidden without permission and public panelaccess still requires authentication. Docker has no exec-kill endpoint. The verified TTY teardown works for the fixedinteractive shells, while a hostile program that changes terminal signalhandling can delay exit. A false teardownConfirmed audit value is an explicitoperational finding; silently claiming the session ended is not acceptable.","headings":[{"id":"0043--container-consoles-use-docker-exec-through-the-panel-proxy","text":"0043 — Container consoles use Docker exec through the panel proxy","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"spike-result","text":"Spike result","level":2,"line":12},{"id":"decision","text":"Decision","level":2,"line":26},{"id":"consequences","text":"Consequences","level":2,"line":51}],"kind":"markdown"},{"slug":"adr/0044-example-projects-live-in-projects-home","title":"0044. Example projects live in Projects Home","description":"0044. Example projects live in Projects Home.","source":"docs/development/adr/0044-example-projects-live-in-projects-home.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr/0044-example-projects-live-in-projects-home","markdown":"# 0044. Example projects live in Projects Home\n\n**Status:** Accepted, amends 0019 and 0028\n\n## Context\n\nThe runnable demonstrations were stored below `docker/examples/` in the Portta\nrepository. That made consumer projects part of the product checkout, denied\neach one its own Git history, and made repository discovery unlike the normal\nProjects Home workflow they were meant to demonstrate.\n\nTests also read those same projects directly. Removing them without a separate\ntest boundary would make CI depend on sibling checkouts or remote repositories.\n\n## Decision\n\nRunnable examples are independent Git repositories at the first level of\n`PORTTA_PROJECTS_HOME`. Their directory names begin with `portta-demo-` so demo\ncommands cannot accidentally start unrelated managed projects.\n\n`portta dev` scans Projects Home and imports every example manifest without\nstarting consumer containers. `--demo` additionally starts the example Compose\nstacks. `up`, `down`, `reset` and `examples apply` use the same location.\n\nPortta keeps reduced fixtures under `tests/fixtures/` and `apps/web/e2e/fixtures/`.\nThose files exist solely to make automated validation and documentation images\ndeterministic; they are not runnable product examples.\n\n## Consequences\n\nDevelopers configure one existing contract, `PORTTA_PROJECTS_HOME`, rather than\na second examples-only path. A checkout without the example repositories may\nstill run `dev`; it reports that example data was skipped. Explicit demo and\napply commands fail with an actionable path when no manifests are present.\n\nThe example repositories own their Compose files, manifests, documentation and\nhistory. Portta's CI stays self-contained and does not fetch those repositories.\n","text":"0044. Example projects live in Projects Home Status: Accepted, amends 0019 and 0028 Context The runnable demonstrations were stored below docker/examples/ in the Porttarepository. That made consumer projects part of the product checkout, deniedeach one its own Git history, and made repository discovery unlike the normalProjects Home workflow they were meant to demonstrate. Tests also read those same projects directly. Removing them without a separatetest boundary would make CI depend on sibling checkouts or remote repositories. Decision Runnable examples are independent Git repositories at the first level ofPORTTA_PROJECTS_HOME. Their directory names begin with portta-demo- so democommands cannot accidentally start unrelated managed projects. portta dev scans Projects Home and imports every example manifest withoutstarting consumer containers. --demo additionally starts the example Composestacks. up, down, reset and examples apply use the same location. Portta keeps reduced fixtures under tests/fixtures/ and apps/web/e2e/fixtures/.Those files exist solely to make automated validation and documentation imagesdeterministic; they are not runnable product examples. Consequences Developers configure one existing contract, PORTTA_PROJECTS_HOME, rather thana second examples-only path. A checkout without the example repositories maystill run dev; it reports that example data was skipped. Explicit demo andapply commands fail with an actionable path when no manifests are present. The example repositories own their Compose files, manifests, documentation andhistory. Portta's CI stays self-contained and does not fetch those repositories.","headings":[{"id":"0044-example-projects-live-in-projects-home","text":"0044. Example projects live in Projects Home","level":1,"line":0},{"id":"context","text":"Context","level":2,"line":4},{"id":"decision","text":"Decision","level":2,"line":14},{"id":"consequences","text":"Consequences","level":2,"line":28}],"kind":"markdown"},{"slug":"adr","title":"Architecture decision records","description":"Architecture decision records.","source":"docs/development/adr/README.md","audience":"developer","section":"Development","category":"Architecture decisions","url":"/docs/adr","markdown":"# Architecture decision records\n\nShort records of decisions that are expensive to reverse. Each states the\ncontext, the decision, and what it costs us.\n\n| # | Decision | Status |\n|---|---|---|\n| [0001](0001-decoupled-infrastructure.md) | The gateway is infrastructure, not a parent project | Accepted, amended by 0030 |\n| [0002](0002-docker-socket-proxy.md) | Traefik reaches Docker through a filtered read-only proxy | Accepted |\n| [0003](0003-traefik-static-config-via-env.md) | Traefik static configuration lives in environment variables | Accepted |\n| [0004](0004-pinned-versions.md) | Every component image pins an explicit version | Accepted |\n| [0005](0005-hostname-convention.md) | Hostnames are derived from the labels Compose already injects | Accepted, amended by 0023 |\n| [0006](0006-compose-project-name-as-namespace.md) | `COMPOSE_PROJECT_NAME` is the namespace for parallel environments | Accepted |\n| [0007](0007-tailscale-sidecar.md) | Traefik runs inside the Tailscale container's network namespace | Accepted |\n| [0008](0008-web-panel-socket-proxy.md) | The web panel gets its own Docker socket proxy | Accepted |\n| [0009](0009-tcp-routing-by-hostname.md) | Databases are told apart by hostname, with TLS terminated at the gateway | Accepted |\n| [0010](0010-git-collected-on-the-host.md) | Git is collected on the host, and the panel only reads the result | Accepted, amended by 0018 |\n| [0011](0011-bounded-traefik-write-surface.md) | The panel has a bounded Traefik write surface | Accepted |\n| [0012](0012-routed-panel-access-requires-authentication.md) | Routed panel access requires authentication | Accepted, refined by 0035 |\n| [0013](0013-what-the-panel-persists.md) | The panel persists decisions and records explicit observations | Accepted |\n| [0014](0014-monorepo-and-the-typescript-cli.md) | The npm monorepo has one TypeScript CLI | Accepted |\n| [0015](0015-node-is-required-on-the-host.md) | Node is required on the host | Accepted |\n| [0016](0016-state-that-could-be-shared.md) | State that could be shared, and what must never be | Accepted |\n| [0017](0017-no-docker-sdk.md) | The panel speaks the Docker Engine API directly, without a general SDK | Accepted |\n| [0018](0018-github-access-lives-in-the-panel.md) | GitHub access lives in the panel, through a GitHub App | Accepted |\n| [0019](0019-compose-files-live-under-docker.md) | The compose files live under `docker/compose/`, one directory per axis | Accepted, amended by 0044 |\n| [0020](0020-installer-and-portta-home.md) | Installing means one directory and published images, not a checkout | Accepted |\n| [0021](0021-panel-access-modes.md) | Panel access is its own decision, and a public panel gets its own entrypoint | Accepted, amended by 0027 and 0035 |\n| [0022](0022-project-domain-modes.md) | The base domain is a mode, and a host with no domain gets one from its address | Accepted |\n| [0023](0023-flat-hostname-labels.md) | A service's whole name lives in one DNS label | Accepted, amends 0005 |\n| [0024](0024-capabilities-providers-endpoints.md) | A service has endpoints, not an access mode | Accepted, amended 2026-09-02 |\n| [0025](0025-cloudflare-tunnel.md) | One tunnel, one wildcard rule, and Traefik keeps routing | Accepted |\n| [0026](0026-applying-settings-from-the-panel.md) | Applying settings uses one isolated runner | Accepted |\n| [0027](0027-forward-authentication-service.md) | ForwardAuth protects project routes | Accepted |\n| [0028](0028-operational-images-live-under-docker.md) | Operational image contexts live under `docker/images/` | Accepted, amended by 0044 |\n| [0029](0029-product-behavior-lives-in-typescript.md) | Product behavior lives in TypeScript | Accepted |\n| [0030](0030-the-panel-and-a-project-lifecycle.md) | The panel may operate a project, without owning it | Accepted, amends 0001 |\n| [0031](0031-projects-home-and-project.md) | Project and Environment are the canonical product nouns | Accepted |\n| [0032](0032-portta-development-model.md) | The Portta development model: Project, Repository, Task, Session, Activity | Accepted, amends 0010/0013/0018/0031; amended by 0038 |\n| [0033](0033-tasks-are-local-issues.md) | Tasks are local issues with sparse board ranks and API credentials | Accepted, amends 0032 |\n| [0034](0034-child-process-output.md) | Child process output follows one CLI policy | Accepted |\n| [0035](0035-authentication-lives-in-the-panel.md) | Authentication lives in the panel | Accepted |\n| [0036](0036-next-app-router-and-the-custom-server.md) | The panel is a Next application on a server of its own | Accepted, amends 0011 |\n| [0037](0037-drizzle-and-a-required-database.md) | Drizzle and PostgreSQL are required | Accepted |\n| [0038](0038-roles-and-project-access.md) | Four roles, and access by Project | Accepted, extends 0035, amends 0032 |\n| [0039](0039-personal-api-tokens.md) | A token belongs to a person, and never exceeds them | Accepted, extends 0035, supersedes the panel tokens in 0033 |\n| [0040](0040-installation-environment-contract.md) | `.env` is the installation configuration contract | Accepted |\n| [0041](0041-cli-interaction-dependencies.md) | Keep the CLI machine-first and dependency-light | Accepted |\n| [0042](0042-portta-owned-ssh-keys.md) | Portta-owned SSH keys use a narrow panel boundary | Accepted |\n| [0043](0043-container-console-over-docker-exec.md) | Container consoles use Docker exec through the panel proxy | Accepted |\n| [0044](0044-example-projects-live-in-projects-home.md) | Example projects live in Projects Home, outside the product repository | Accepted, amends 0019 and 0028 |\n","text":"Architecture decision records Short records of decisions that are expensive to reverse. Each states thecontext, the decision, and what it costs us. # Decision Status 0001 The gateway is infrastructure, not a parent project Accepted, amended by 0030 0002 Traefik reaches Docker through a filtered read-only proxy Accepted 0003 Traefik static configuration lives in environment variables Accepted 0004 Every component image pins an explicit version Accepted 0005 Hostnames are derived from the labels Compose already injects Accepted, amended by 0023 0006 COMPOSE_PROJECT_NAME is the namespace for parallel environments Accepted 0007 Traefik runs inside the Tailscale container's network namespace Accepted 0008 The web panel gets its own Docker socket proxy Accepted 0009 Databases are told apart by hostname, with TLS terminated at the gateway Accepted 0010 Git is collected on the host, and the panel only reads the result Accepted, amended by 0018 0011 The panel has a bounded Traefik write surface Accepted 0012 Routed panel access requires authentication Accepted, refined by 0035 0013 The panel persists decisions and records explicit observations Accepted 0014 The npm monorepo has one TypeScript CLI Accepted 0015 Node is required on the host Accepted 0016 State that could be shared, and what must never be Accepted 0017 The panel speaks the Docker Engine API directly, without a general SDK Accepted 0018 GitHub access lives in the panel, through a GitHub App Accepted 0019 The compose files live under docker/compose/, one directory per axis Accepted, amended by 0044 0020 Installing means one directory and published images, not a checkout Accepted 0021 Panel access is its own decision, and a public panel gets its own entrypoint Accepted, amended by 0027 and 0035 0022 The base domain is a mode, and a host with no domain gets one from its address Accepted 0023 A service's whole name lives in one DNS label Accepted, amends 0005 0024 A service has endpoints, not an access mode Accepted, amended 2026-09-02 0025 One tunnel, one wildcard rule, and Traefik keeps routing Accepted 0026 Applying settings uses one isolated runner Accepted 0027 ForwardAuth protects project routes Accepted 0028 Operational image contexts live under docker/images/ Accepted, amended by 0044 0029 Product behavior lives in TypeScript Accepted 0030 The panel may operate a project, without owning it Accepted, amends 0001 0031 Project and Environment are the canonical product nouns Accepted 0032 The Portta development model: Project, Repository, Task, Session, Activity Accepted, amends 0010/0013/0018/0031; amended by 0038 0033 Tasks are local issues with sparse board ranks and API credentials Accepted, amends 0032 0034 Child process output follows one CLI policy Accepted 0035 Authentication lives in the panel Accepted 0036 The panel is a Next application on a server of its own Accepted, amends 0011 0037 Drizzle and PostgreSQL are required Accepted 0038 Four roles, and access by Project Accepted, extends 0035, amends 0032 0039 A token belongs to a person, and never exceeds them Accepted, extends 0035, supersedes the panel tokens in 0033 0040 .env is the installation configuration contract Accepted 0041 Keep the CLI machine-first and dependency-light Accepted 0042 Portta-owned SSH keys use a narrow panel boundary Accepted 0043 Container consoles use Docker exec through the panel proxy Accepted 0044 Example projects live in Projects Home, outside the product repository Accepted, amends 0019 and 0028","headings":[{"id":"architecture-decision-records","text":"Architecture decision records","level":1,"line":0}],"kind":"markdown"}],"aliases":{},"groups":[{"title":"Getting Started","category":"","audience":"user","sequential":true,"slugs":["install","first-environment","first-project"]},{"title":"Guides","category":"Projects and environments","audience":"user","sequential":false,"slugs":["web-ui","adopting-projects","projects","environments","services","container-console","local-development","monorepos"]},{"title":"Guides","category":"Domains and networking","audience":"user","sequential":false,"slugs":["dns-and-tls","cloudflare","firewall"]},{"title":"Guides","category":"Remote access","audience":"user","sequential":false,"slugs":["remote-development","remote-bootstrap","ssh-keys","tailscale","tailscale-services","cloudflare-tunnel","public-access","remote-tunnels"]},{"title":"Guides","category":"Authentication and sharing","audience":"user","sequential":false,"slugs":["authentication","sharing"]},{"title":"Guides","category":"Databases and TCP services","audience":"user","sequential":false,"slugs":["database-access","tcp-access","tcp-routing","redis-access"]},{"title":"Guides","category":"Tasks and integrations","audience":"user","sequential":false,"slugs":["tasks","github","use-api"]},{"title":"Guides","category":"Maintenance and troubleshooting","audience":"user","sequential":false,"slugs":["panel-settings","update","backup-restore","troubleshooting"]},{"title":"Reference","category":"","audience":"user","sequential":false,"slugs":["configuration","installation-reference","cli","api","mcp","host-metrics","host-readiness","compatibility","changelog"]},{"title":"Concepts","category":"","audience":"user","sequential":false,"slugs":["architecture","project-model","addresses-and-access","networking","persistence","security","github-sync"]},{"title":"Development","category":"","audience":"developer","sequential":false,"slugs":["development-setup","panel-architecture","monorepo","database-development","scripts","testing","publish-cli","design-system","documentation"]},{"title":"Development","category":"Architecture decisions","audience":"developer","sequential":false,"slugs":["adr/0001-decoupled-infrastructure","adr/0002-docker-socket-proxy","adr/0003-traefik-static-config-via-env","adr/0004-pinned-versions","adr/0005-hostname-convention","adr/0006-compose-project-name-as-namespace","adr/0007-tailscale-sidecar","adr/0008-web-panel-socket-proxy","adr/0009-tcp-routing-by-hostname","adr/0010-git-collected-on-the-host","adr/0011-bounded-traefik-write-surface","adr/0012-routed-panel-access-requires-authentication","adr/0013-what-the-panel-persists","adr/0014-monorepo-and-the-typescript-cli","adr/0015-node-is-required-on-the-host","adr/0016-state-that-could-be-shared","adr/0017-no-docker-sdk","adr/0018-github-access-lives-in-the-panel","adr/0019-compose-files-live-under-docker","adr/0020-installer-and-portta-home","adr/0021-panel-access-modes","adr/0022-project-domain-modes","adr/0023-flat-hostname-labels","adr/0024-capabilities-providers-endpoints","adr/0025-cloudflare-tunnel","adr/0026-applying-settings-from-the-panel","adr/0027-forward-authentication-service","adr/0028-operational-images-live-under-docker","adr/0029-product-behavior-lives-in-typescript","adr/0030-the-panel-and-a-project-lifecycle","adr/0031-projects-home-and-project","adr/0032-portta-development-model","adr/0033-tasks-are-local-issues","adr/0034-child-process-output","adr/0035-authentication-lives-in-the-panel","adr/0036-next-app-router-and-the-custom-server","adr/0037-drizzle-and-a-required-database","adr/0038-roles-and-project-access","adr/0039-personal-api-tokens","adr/0040-installation-environment-contract","adr/0041-cli-interaction-dependencies","adr/0042-portta-owned-ssh-keys","adr/0043-container-console-over-docker-exec","adr/0044-example-projects-live-in-projects-home","adr"]}]}