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,21 @@
1
+ # ============================================================================
2
+ # Portta: build the panel image locally (development only)
3
+ # ============================================================================
4
+ # Applied when PORTTA_WEB_BUILD=true, which only makes sense inside a checkout:
5
+ # the build context is the repository root, and an installed PORTTA_HOME holds
6
+ # no application source at all.
7
+ #
8
+ # A normal installation pulls the published image instead, so a host never
9
+ # needs a compiler, a lockfile or the panel's source to run the panel.
10
+ # See docs/development/adr/0020-installer-and-portta-home.md.
11
+ # ============================================================================
12
+
13
+ services:
14
+ web:
15
+ build:
16
+ context: .
17
+ dockerfile: apps/web/Dockerfile
18
+ target: runtime
19
+ args:
20
+ PORTTA_VERSION: ${PORTTA_VERSION:-}
21
+ image: ${PORTTA_WEB_IMAGE:-fabioassuncao/portta:${PORTTA_VERSION}}
@@ -0,0 +1,53 @@
1
+ # ============================================================================
2
+ # Portta: panel in development mode
3
+ # ============================================================================
4
+ # Applied when PORTTA_WEB_DEV=true, or by `portta web dev`.
5
+ #
6
+ # One container, one port. The panel is a single Node process — Next, the Hono
7
+ # API, the event stream and the WebSocket upgrades all behind one dispatcher —
8
+ # so HMR arrives on the same 8081 the API answers on.
9
+ #
10
+ # Only source directories are bind-mounted, so the image's node_modules stay in
11
+ # place and the host never needs Node installed.
12
+ # ============================================================================
13
+
14
+ services:
15
+ web:
16
+ build:
17
+ context: .
18
+ dockerfile: apps/web/Dockerfile
19
+ target: dev
20
+ args:
21
+ PORTTA_VERSION: ${PORTTA_VERSION:-}
22
+ image: fabioassuncao/portta:dev
23
+ # The package script, never a restated command line: it carries
24
+ # `--conditions=development`, without which the workspace packages resolve
25
+ # to a dist/ the dev image does not build.
26
+ command: ["npm", "run", "dev"]
27
+ environment:
28
+ NODE_ENV: development
29
+ volumes:
30
+ - ./apps/web/app:/app/apps/web/app
31
+ - ./apps/web/components:/app/apps/web/components
32
+ - ./apps/web/lib:/app/apps/web/lib
33
+ - ./apps/web/messages:/app/apps/web/messages
34
+ - ./apps/web/server:/app/apps/web/server
35
+ - ./apps/web/public:/app/apps/web/public
36
+ # The packages the panel composes, so editing one reloads it like its own
37
+ # source: the shared derivations, the contract, the schema, auth, and the
38
+ # services and API.
39
+ - ./packages/core/src:/app/packages/core/src
40
+ - ./packages/contracts/src:/app/packages/contracts/src
41
+ - ./packages/auth/src:/app/packages/auth/src
42
+ - ./packages/db/src:/app/packages/db/src
43
+ # The generated migrations the process applies on the next migrate.
44
+ # Without this a newly generated file stays in the image until the next
45
+ # rebuild, so `portta db migrate` and a restart would both see a stale set.
46
+ - ./packages/db/drizzle:/app/packages/db/drizzle
47
+ - ./packages/server/src:/app/packages/server/src
48
+ # The documentation itself: `app/docs` reads and renders it, so a change
49
+ # to a Markdown file is visible on the next request.
50
+ - ./docs:/app/docs:ro
51
+ - ./README.md:/app/README.md:ro
52
+ - ./CHANGELOG.md:/app/CHANGELOG.md:ro
53
+ - ./.env:/app/state/.env
@@ -0,0 +1,24 @@
1
+ # ============================================================================
2
+ # Portta: reach the panel over the VPN
3
+ # ============================================================================
4
+ # Applied when PORTTA_WEB_EXPOSE=vpn. It adds a Traefik router for the
5
+ # panel so it is reachable at <host>.<domain> from the tailnet, instead of only
6
+ # on the VPS's own loopback.
7
+ #
8
+ # The CLI refuses this overlay on the remote-public profile, where Traefik
9
+ # binds every interface and a router would therefore be public. It must never
10
+ # be reachable from the internet.
11
+ #
12
+ # There is no middleware on the router: the panel signs people in itself, and
13
+ # `portta web up --expose vpn` refuses unless PORTTA_AUTH_MODE=required. The
14
+ # tailnet is the first boundary and the panel's own login is the second.
15
+ # See docs/development/adr/0035-authentication-lives-in-the-panel.md.
16
+ # ============================================================================
17
+
18
+ services:
19
+ web:
20
+ labels:
21
+ traefik.enable: "true"
22
+ traefik.http.routers.portta-web.rule: >-
23
+ Host(`${PORTTA_WEB_HOST:-portta-web}.${PORTTA_DOMAIN:-localhost}`)
24
+ traefik.http.services.portta-web.loadbalancer.server.port: "8081"
@@ -0,0 +1,283 @@
1
+ # ============================================================================
2
+ # Portta: administration panel (opt-in)
3
+ # ============================================================================
4
+ # Enabled by PORTTA_WEB=true, or by `portta web up`.
5
+ #
6
+ # The panel is a development tool, so it is published on loopback by default
7
+ # and never routed through the public entrypoints. Reaching it over a VPN is a
8
+ # separate, explicit overlay: docker/compose/features/web-vpn.yaml.
9
+ #
10
+ # It gets its OWN Docker socket proxy rather than sharing Traefik's. Traefik
11
+ # needs a strictly read-only view; the panel needs to start, stop, restart and
12
+ # remove containers, and those two permission sets must not be merged into one.
13
+ # See docs/product/guides/web-ui.md and docs/development/adr/0008-web-panel-socket-proxy.md.
14
+ # ============================================================================
15
+
16
+ networks:
17
+ # Panel <-> its socket proxy. `internal: true`: the proxy has no route to the
18
+ # outside world and is reachable from nothing else.
19
+ webcontrol:
20
+ name: ${PORTTA_WEB_NETWORK:-portta-web}
21
+ internal: true
22
+ labels:
23
+ portta.managed: "true"
24
+ portta.component: web-control-network
25
+
26
+ services:
27
+ # --------------------------------------------------------------------------
28
+ # The panel's Docker socket proxy
29
+ # --------------------------------------------------------------------------
30
+ # Write access is limited to container lifecycle and interactive exec.
31
+ # Images, volumes, build, swarm and system endpoints (where prune lives) stay
32
+ # denied, and the panel enforces its narrower path allowlist on top.
33
+ web-socket-proxy:
34
+ image: tecnativa/docker-socket-proxy:v0.5.0
35
+ restart: unless-stopped
36
+ read_only: true
37
+ environment:
38
+ # -- read ------------------------------------------------------------
39
+ CONTAINERS: "1"
40
+ NETWORKS: "1"
41
+ EVENTS: "1"
42
+ INFO: "1"
43
+ VERSION: "1"
44
+ PING: "1"
45
+ # -- write, limited to the container lifecycle ----------------------
46
+ POST: "1"
47
+ ALLOW_START: "1"
48
+ ALLOW_STOP: "1"
49
+ ALLOW_RESTARTS: "1"
50
+ # -- denied ----------------------------------------------------------
51
+ ALLOW_PAUSE: "0"
52
+ ALLOW_UNPAUSE: "0"
53
+ AUTH: "0"
54
+ BUILD: "0"
55
+ COMMIT: "0"
56
+ CONFIGS: "0"
57
+ DISTRIBUTION: "0"
58
+ # Four exec paths are allowed again inside the panel: create, attach,
59
+ # resize and inspect. Traefik's separate proxy remains read-only.
60
+ EXEC: "1"
61
+ GRPC: "0"
62
+ IMAGES: "0"
63
+ NODES: "0"
64
+ PLUGINS: "0"
65
+ SECRETS: "0"
66
+ SERVICES: "0"
67
+ SESSION: "0"
68
+ SWARM: "0"
69
+ SYSTEM: "0"
70
+ TASKS: "0"
71
+ VOLUMES: "0"
72
+ LOG_LEVEL: warning
73
+ volumes:
74
+ - /var/run/docker.sock:/var/run/docker.sock:ro
75
+ networks:
76
+ - webcontrol
77
+ # No `ports:`. The proxy is reachable only from the panel.
78
+ tmpfs:
79
+ - /tmp
80
+ - /run
81
+ healthcheck:
82
+ test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
83
+ interval: 10s
84
+ timeout: 3s
85
+ retries: 5
86
+ start_period: 5s
87
+ labels:
88
+ traefik.enable: "false"
89
+ portta.managed: "true"
90
+ portta.component: web-socket-proxy
91
+
92
+ # --------------------------------------------------------------------------
93
+ # The panel: API and UI in one process
94
+ # --------------------------------------------------------------------------
95
+ web:
96
+ # A normal installation never builds: the panel is a published image, and
97
+ # PORTTA_HOME holds no application source to build from. Developers opt
98
+ # back into a local build with PORTTA_WEB_BUILD=true
99
+ # (docker/compose/features/web-build.yaml), and `portta web dev` still
100
+ # builds the dev target. PORTTA_VERSION comes from the generated runtime
101
+ # asset, so the installed CLI and panel select the same image.
102
+ image: ${PORTTA_WEB_IMAGE:-ghcr.io/fabioassuncao/portta:${PORTTA_VERSION}}
103
+ restart: unless-stopped
104
+ depends_on:
105
+ web-socket-proxy:
106
+ condition: service_healthy
107
+ # `node` is in both the runtime and the `dev` image; the Dockerfile's
108
+ # `wget` healthcheck only exists on the runtime stage. start_period covers
109
+ # the first `next.prepare()`; retries then fit `--wait-timeout 180`.
110
+ healthcheck:
111
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8081/api/health').then((r)=>process.exit(r.ok||r.status===401?0:1)).catch(()=>process.exit(1))"]
112
+ interval: 5s
113
+ timeout: 3s
114
+ retries: 24
115
+ start_period: 60s
116
+ # Whoever owns .env, which the Settings page writes. `bootstrap` and
117
+ # `web up` record it; the image's own `node` is only a last resort, and is
118
+ # right just when the host uid happens to be 1000.
119
+ user: ${PORTTA_WEB_USER:-node}
120
+ environment:
121
+ PORTTA_RUNTIME_DB_MODE: ${PORTTA_RUNTIME_DB_MODE:-managed}
122
+ PORTTA_RUNTIME_DB_NAME: ${PORTTA_RUNTIME_DB_NAME:-portta}
123
+ PORTTA_RUNTIME_DB_USER: ${PORTTA_RUNTIME_DB_USER:-portta}
124
+ PORTTA_RUNTIME_DB_PASSWORD: ${PORTTA_RUNTIME_DB_PASSWORD:-}
125
+ PORTTA_RUNTIME_DATABASE_URL: ${PORTTA_RUNTIME_DATABASE_URL:-}
126
+ PORTTA_AUTH_SIGNIN_ATTEMPTS: ${PORTTA_AUTH_SIGNIN_ATTEMPTS:-}
127
+ ACME_CHALLENGE: ${ACME_CHALLENGE:-dns}
128
+ ACME_DNS_RESOLVERS: ${ACME_DNS_RESOLVERS:-}
129
+ CLOUDFLARE_TUNNEL_ENABLED: ${CLOUDFLARE_TUNNEL_ENABLED:-false}
130
+ CLOUDFLARE_TUNNEL_ZONE: ${CLOUDFLARE_TUNNEL_ZONE:-}
131
+ PORTTA_RUNTIME_DOCKER_API: http://web-socket-proxy:2375
132
+ PORTTA_RUNTIME_HOST: 0.0.0.0
133
+ PORTTA_RUNTIME_PORT: "8081"
134
+ PORTTA_RUNTIME_ENV_FILE: /app/state/.env
135
+ PORTTA_RUNTIME_READ_ONLY: ${PORTTA_WEB_READ_ONLY:-false}
136
+ # The machine contract is always served; its browser defaults on only
137
+ # while the panel stays on loopback.
138
+ PORTTA_RUNTIME_DOCS: ${PORTTA_RUNTIME_DOCS:-true}
139
+ PORTTA_RUNTIME_API_DOCS: ${PORTTA_RUNTIME_API_DOCS:-}
140
+ # The resolved gateway configuration, so the panel and the CLI always
141
+ # describe the same gateway.
142
+ PORTTA_PROFILE: ${PORTTA_PROFILE:-local}
143
+ # Projects Home as a string, for location classification only: no
144
+ # project directory is ever mounted here (docs/development/adr/0031-projects-home-and-project.md).
145
+ PORTTA_PROJECTS_HOME: ${PORTTA_PROJECTS_HOME:-}
146
+ PORTTA_PROJECT_NAME: ${PORTTA_PROJECT_NAME:-portta}
147
+ PORTTA_NETWORK: ${PORTTA_NETWORK:-portta}
148
+ PORTTA_CONTROL_NETWORK: ${PORTTA_CONTROL_NETWORK:-portta-control}
149
+ PORTTA_ACCESS_NETWORK: ${PORTTA_ACCESS_NETWORK:-portta-access}
150
+ PORTTA_WEB_NETWORK: ${PORTTA_WEB_NETWORK:-portta-web}
151
+ PORTTA_TCP: ${PORTTA_TCP:-false}
152
+ PORTTA_TCP_POSTGRES_PORT: ${PORTTA_TCP_POSTGRES_PORT:-5432}
153
+ PORTTA_TCP_REDIS_PORT: ${PORTTA_TCP_REDIS_PORT:-6379}
154
+ PORTTA_DOMAIN: ${PORTTA_DOMAIN:-localhost}
155
+ # How that base was chosen, so the Settings page can show the mode and
156
+ # the address behind it rather than a bare hostname with no explanation.
157
+ # See docs/development/adr/0022-project-domain-modes.md.
158
+ PORTTA_DOMAIN_MODE: ${PORTTA_DOMAIN_MODE:-local}
159
+ PORTTA_PUBLIC_IP: ${PORTTA_PUBLIC_IP:-}
160
+ PORTTA_AUTO_DOMAIN_PROVIDER: ${PORTTA_AUTO_DOMAIN_PROVIDER:-sslip.io}
161
+ PORTTA_BIND_ADDRESS: ${PORTTA_BIND_ADDRESS:-127.0.0.1}
162
+ PORTTA_HTTP_PORT: ${PORTTA_HTTP_PORT:-80}
163
+ PORTTA_HTTPS_PORT: ${PORTTA_HTTPS_PORT:-443}
164
+ PORTTA_DASHBOARD: ${PORTTA_DASHBOARD:-false}
165
+ PORTTA_DASHBOARD_BIND_ADDRESS: ${PORTTA_DASHBOARD_BIND_ADDRESS:-127.0.0.1}
166
+ PORTTA_DASHBOARD_PORT: ${PORTTA_DASHBOARD_PORT:-8080}
167
+ # Traefik's own API, read-only, and only when the dashboard is enabled.
168
+ # The panel resolves the host from the attachment (`traefik` normally,
169
+ # `tailscale` when Traefik shares that namespace) and reaches it over the
170
+ # shared network it is already on, never over `control`: see
171
+ # docs/development/adr/0011-bounded-traefik-write-surface.md.
172
+ PORTTA_RUNTIME_TRAEFIK_API: ${PORTTA_RUNTIME_TRAEFIK_API:-}
173
+ PORTTA_LOG_LEVEL: ${PORTTA_LOG_LEVEL:-INFO}
174
+ PORTTA_ACCESS_LOG: ${PORTTA_ACCESS_LOG:-false}
175
+ PORTTA_WEB_PORT: ${PORTTA_WEB_PORT:-8081}
176
+ PORTTA_WEB_BIND_ADDRESS: ${PORTTA_WEB_BIND_ADDRESS:-127.0.0.1}
177
+ PORTTA_WEB_EXPOSE: ${PORTTA_WEB_EXPOSE:-local}
178
+ # The panel's own front door, which is now the panel's own business.
179
+ #
180
+ # `required` makes it sign people in: accounts, roles, sessions and
181
+ # tokens, all in its database. `disabled` makes every request the local
182
+ # operator, and the process refuses to start that way on anything but
183
+ # loopback. The secret signs sessions and is shared with the ForwardAuth
184
+ # service, which is the only other thing here that signs anything.
185
+ #
186
+ # PORTTA_PANEL_URL is where a browser reaches it, and three things come
187
+ # from it: whether the session cookie may be `Secure`, which origins a
188
+ # write is accepted from, and the address the panel prints. Empty means
189
+ # loopback on the published port.
190
+ PORTTA_AUTH_MODE: ${PORTTA_AUTH_MODE:-disabled}
191
+ PORTTA_AUTH_SECRET: ${PORTTA_AUTH_SECRET:-}
192
+ PORTTA_PANEL_URL: ${PORTTA_PANEL_URL:-}
193
+ PORTTA_PANEL_TRUSTED_ORIGINS: ${PORTTA_PANEL_TRUSTED_ORIGINS:-}
194
+ PORTTA_RUNTIME_DYNAMIC_DIR: /app/state/traefik-dynamic
195
+ PORTTA_RUNTIME_AUTH_STORE: /app/state/auth/protections.json
196
+ PORTTA_RUNTIME_RUNNER_DIR: /app/state/runner
197
+ PORTTA_RUNTIME_ACCESS_DIR: /app/state/access
198
+ PORTTA_RUNTIME_SSH_DIR: /app/state/ssh
199
+ PORTTA_PANEL_ADVERTISED_HOST: ${PORTTA_PANEL_ADVERTISED_HOST:-}
200
+ TLS_ENABLED: ${TLS_ENABLED:-false}
201
+ TLS_MODE: ${TLS_MODE:-local}
202
+ ACME_EMAIL: ${ACME_EMAIL:-}
203
+ ACME_CA_SERVER: ${ACME_CA_SERVER:-https://acme-v02.api.letsencrypt.org/directory}
204
+ ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
205
+ TAILSCALE_ENABLED: ${TAILSCALE_ENABLED:-false}
206
+ TAILSCALE_HOSTNAME: ${TAILSCALE_HOSTNAME:-portta}
207
+ PRIVATE_DOMAIN: ${PRIVATE_DOMAIN:-}
208
+ PUBLIC_ENABLED: ${PUBLIC_ENABLED:-false}
209
+ PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-}
210
+ CLOUDFLARE_ENABLED: ${CLOUDFLARE_ENABLED:-false}
211
+ CLOUDFLARE_ZONE: ${CLOUDFLARE_ZONE:-}
212
+ # The panel's only outbound integration, and its only long-lived secret
213
+ # that is not a Traefik credential. Off by default: with this false the
214
+ # panel makes no request to github.com. The private key is passed as a
215
+ # PATH, never as a value, because the panel can write this .env and must
216
+ # not be able to write the key that authenticates it.
217
+ #
218
+ # The path is the operator's: the Settings field writes it here, and this
219
+ # is the value the panel reads. What is fixed is the DIRECTORY, because
220
+ # `./state/github` below is the only place the key is mounted from; the
221
+ # filename is whatever GitHub called the .pem. The panel refuses a path
222
+ # outside that directory on save, and `portta doctor` fails on one.
223
+ # See docs/product/guides/github.md and docs/product/concepts/security.md.
224
+ GITHUB_APP_ENABLED: ${GITHUB_APP_ENABLED:-false}
225
+ GITHUB_APP_ID: ${GITHUB_APP_ID:-}
226
+ GITHUB_APP_PRIVATE_KEY_FILE: ${GITHUB_APP_PRIVATE_KEY_FILE:-/app/state/github/app.pem}
227
+ GITHUB_APP_WEBHOOK_SECRET: ${GITHUB_APP_WEBHOOK_SECRET:-}
228
+ GITHUB_API_URL: ${GITHUB_API_URL:-https://api.github.com}
229
+ GITHUB_SYNC_INTERVAL_MINUTES: ${GITHUB_SYNC_INTERVAL_MINUTES:-15}
230
+ volumes:
231
+ # Explicit paths from the host, and nothing else. No project directory is ever
232
+ # mounted here: see docs/development/adr/0001-decoupled-infrastructure.md.
233
+ #
234
+ # .env the configuration the Settings page edits
235
+ # state/git metadata `portta repos scan` collected, read-only
236
+ # state/metrics current.json + history from `portta host watch`
237
+ # state/environment host readiness report from the same collector
238
+ # state/runner the `{ verb, project }` request the runner reads
239
+ # state/access tunnel records cleaned when a project is removed
240
+ # state/github the GitHub App private key, read-only
241
+ # state/auth credentials the panel manages, never served directly
242
+ # state/ssh SSH credentials the panel manages, never served directly
243
+ # traefik/dynamic read-write, and the ONE place the panel configures
244
+ # Traefik. It may write four generated filenames there
245
+ # and refuses every other path in its own process.
246
+ # See docs/development/adr/0011-bounded-traefik-write-surface.md.
247
+ - ./.env:/app/state/.env
248
+ - ./.env.example:/app/state/.env.example:ro
249
+ - ./.env-lock:/app/state/.env-lock
250
+ - ./state/git:/app/state/git:ro
251
+ - ./state/metrics:/app/state/metrics:ro
252
+ - ./state/environment:/app/state/environment:ro
253
+ - ./state/runner:/app/state/runner
254
+ - ./state/access:/app/state/access
255
+ # The GitHub App private key, read-only. Empty unless you put one there.
256
+ - ./state/github:/app/state/github:ro
257
+ - ./state/auth:/app/state/auth
258
+ - ./state/ssh:/app/state/ssh
259
+ - ./config/traefik/dynamic:/app/state/traefik-dynamic
260
+ # The Cloudflare Tunnel connector's generated config and its credential.
261
+ # Read-write because setting the tunnel up is something the panel is asked
262
+ # to do; the directory stays 0700 and the credentials file 0600, and the
263
+ # token itself is never stored anywhere else — not in .env, not in a log,
264
+ # not in any API response. See docs/development/adr/0025-cloudflare-tunnel.md.
265
+ - ./state/cloudflared:/app/state/cloudflared
266
+ networks:
267
+ # `gateway` so the panel can be published and, with the VPN overlay,
268
+ # routed by Traefik. `webcontrol` for the socket proxy.
269
+ - gateway
270
+ - webcontrol
271
+ # Deliberately no `ports:` here. Exactly one overlay decides how the panel
272
+ # is reached, so the two ways can never both bind the same host port:
273
+ # docker/compose/features/web-bind.yaml publishes it on PORTTA_WEB_BIND_ADDRESS
274
+ # docker/compose/features/panel-public.yaml routes it through Traefik's
275
+ # own `panel` entrypoint
276
+ # docker/compose/features/panel-domain.yaml routes it on one hostname of
277
+ # the gateway's own domain
278
+ labels:
279
+ # Off unless docker/compose/features/web-vpn.yaml turns it on: the panel is
280
+ # never routed through the public entrypoints by accident.
281
+ traefik.enable: "false"
282
+ portta.managed: "true"
283
+ portta.component: web
@@ -0,0 +1,23 @@
1
+ # ============================================================================
2
+ # `local` profile with HTTPS from a local CA
3
+ # ============================================================================
4
+ # Applied automatically when TLS_ENABLED=true and TLS_MODE=local.
5
+ # The certificate itself is generated by `portta tls init` and offered to
6
+ # Traefik through the file provider (config/traefik/dynamic/local-tls.yaml).
7
+ # ============================================================================
8
+
9
+ services:
10
+ traefik:
11
+ environment:
12
+ # Turn TLS on for the entrypoint itself, so a project gets HTTPS without
13
+ # a single certificate label. Without this, routers attached to :443
14
+ # would answer there in plaintext.
15
+ TRAEFIK_ENTRYPOINTS_WEBSECURE_HTTP_TLS: "true"
16
+ # Routers now default to :443, and :80 redirects there.
17
+ TRAEFIK_ENTRYPOINTS_WEB_ASDEFAULT: "false"
18
+ TRAEFIK_ENTRYPOINTS_WEBSECURE_ASDEFAULT: "true"
19
+ TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_TO: websecure
20
+ TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_SCHEME: https
21
+ # Not permanent: a 301 would be cached by the browser and would outlive
22
+ # someone turning local TLS back off.
23
+ TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_PERMANENT: "false"
@@ -0,0 +1,15 @@
1
+ # ============================================================================
2
+ # Portta: `local` profile overlay (macOS / Linux workstation)
3
+ # ============================================================================
4
+ # Plain HTTP on loopback, no TLS, no certificates to trust. Hostnames end in
5
+ # `.localhost`, which resolves to loopback without touching /etc/hosts.
6
+ #
7
+ # Applied automatically by: ./bin/portta up local
8
+ # ============================================================================
9
+
10
+ services:
11
+ traefik:
12
+ environment:
13
+ # With no TLS configured, routers that do not name an entrypoint should
14
+ # attach to :80 only: otherwise they would also answer plaintext on :443.
15
+ TRAEFIK_ENTRYPOINTS_WEB_ASDEFAULT: "true"
@@ -0,0 +1,19 @@
1
+ # ============================================================================
2
+ # `remote-public` profile: the internet can reach this
3
+ # ============================================================================
4
+ # Opt-in, and deliberately never the default. Enabling it only changes who can
5
+ # reach Traefik; it does not publish anything new. A service is still routed
6
+ # only when it sets traefik.enable=true, and databases and caches are still
7
+ # never on the shared network.
8
+ #
9
+ # PORTTA_BIND_ADDRESS is forced to 0.0.0.0 by the CLI for this profile.
10
+ # ============================================================================
11
+
12
+ services:
13
+ traefik:
14
+ environment:
15
+ # Facing the open internet, header aliasing stops being theoretical: a
16
+ # client can spoof a header Traefik manages if a backend normalises
17
+ # underscores. Strip them here rather than trusting every backend.
18
+ TRAEFIK_ENTRYPOINTS_WEB_HTTP_ALIASHEADERSSTRATEGY: ${PORTTA_ALIAS_HEADERS_STRATEGY:-delete}
19
+ TRAEFIK_ENTRYPOINTS_WEBSECURE_HTTP_ALIASHEADERSSTRATEGY: ${PORTTA_ALIAS_HEADERS_STRATEGY:-delete}
@@ -0,0 +1,26 @@
1
+ # ============================================================================
2
+ # ACME over DNS-01: one wildcard certificate for the whole domain
3
+ # ============================================================================
4
+ # The default, and the only challenge that can issue `*.example.com`. It is
5
+ # what makes a hostname work over HTTPS *before* anything is running on it,
6
+ # and the only option for a private gateway the ACME server can never reach:
7
+ # Let's Encrypt has to see a TXT record, not this host.
8
+ #
9
+ # The cost is a DNS provider credential. `remote-tls-http.yaml` is the trade
10
+ # in the other direction. See docs/product/guides/dns-and-tls.md.
11
+ # ============================================================================
12
+
13
+ services:
14
+ traefik:
15
+ environment:
16
+ TRAEFIK_ENTRYPOINTS_WEBSECURE_HTTP_TLS_DOMAINS_0_MAIN: ${PORTTA_DOMAIN}
17
+ TRAEFIK_ENTRYPOINTS_WEBSECURE_HTTP_TLS_DOMAINS_0_SANS: "*.${PORTTA_DOMAIN}"
18
+
19
+ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_DNSCHALLENGE: "true"
20
+ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_DNSCHALLENGE_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
21
+ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_DNSCHALLENGE_RESOLVERS: ${ACME_DNS_RESOLVERS:-1.1.1.1:53,8.8.8.8:53}
22
+
23
+ # -- DNS provider credentials ----------------------------------------
24
+ # Read by lego inside Traefik. Empty unless the provider is configured;
25
+ # a scoped token, never a global API key. See docs/product/guides/cloudflare.md.
26
+ CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:-}
@@ -0,0 +1,32 @@
1
+ # ============================================================================
2
+ # ACME over HTTP-01: one certificate per hostname, and no credential
3
+ # ============================================================================
4
+ # What a public gateway on a public IP can do with nothing but an A record and
5
+ # a wildcard A record. Traefik asks for a certificate the first time a router
6
+ # is created for a hostname, Let's Encrypt fetches a token from this host over
7
+ # :80, and the certificate arrives a second or two later.
8
+ #
9
+ # No entrypoint-level `domains` here, deliberately: a wildcard SAN cannot be
10
+ # issued this way, and asking for one makes every issuance fail. The router's
11
+ # own rule is what names the certificate.
12
+ #
13
+ # What this gives up against DNS-01:
14
+ #
15
+ # - :80 must be reachable from the internet. A private or VPN-only gateway
16
+ # cannot use this at all.
17
+ # - A hostname has no certificate until something answers on it, so the very
18
+ # first request to a new service waits for issuance.
19
+ # - Let's Encrypt counts certificates per registered domain, not per name.
20
+ # A gateway that churns through hostnames will meet the weekly limit that
21
+ # one wildcard never would.
22
+ #
23
+ # Traefik installs the challenge router ahead of the :80 -> :443 redirection
24
+ # above, so the redirect does not swallow the challenge.
25
+ # See docs/product/guides/dns-and-tls.md.
26
+ # ============================================================================
27
+
28
+ services:
29
+ traefik:
30
+ environment:
31
+ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE: "true"
32
+ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE_ENTRYPOINT: web
@@ -0,0 +1,31 @@
1
+ # ============================================================================
2
+ # Remote profiles with TLS: the parts both ACME challenges share
3
+ # ============================================================================
4
+ # Applied on a remote profile when TLS_ENABLED=true, together with exactly one
5
+ # challenge overlay: remote-tls-dns.yaml or remote-tls-http.yaml.
6
+ #
7
+ # Deliberately not applied when TLS is off. Redirecting to an entrypoint with
8
+ # no usable certificate turns a working HTTP URL into a browser warning, which
9
+ # is exactly what a host on an auto domain has: no public CA issues a wildcard
10
+ # for sslip.io, and neither service offers the DNS-01 API one would need.
11
+ # docker/compose/profiles/remote.yaml serves those over plain HTTP instead.
12
+ # See docs/development/adr/0022-project-domain-modes.md.
13
+ # ============================================================================
14
+
15
+ services:
16
+ traefik:
17
+ environment:
18
+ # Routers that name no entrypoint attach to :443 only; :80 just redirects.
19
+ TRAEFIK_ENTRYPOINTS_WEBSECURE_ASDEFAULT: "true"
20
+ TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_TO: websecure
21
+ TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_SCHEME: https
22
+ TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_PERMANENT: "true"
23
+
24
+ # TLS terminates at the entrypoint, so a project gets HTTPS without a
25
+ # single certificate label of its own.
26
+ TRAEFIK_ENTRYPOINTS_WEBSECURE_HTTP_TLS_CERTRESOLVER: letsencrypt
27
+
28
+ # -- ACME ------------------------------------------------------------
29
+ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL: ${ACME_EMAIL:-}
30
+ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_CASERVER: ${ACME_CA_SERVER:-https://acme-v02.api.letsencrypt.org/directory}
31
+ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_STORAGE: /etc/traefik/acme/acme.json
@@ -0,0 +1,23 @@
1
+ # ============================================================================
2
+ # Remote profiles without TLS: plain HTTP on :80
3
+ # ============================================================================
4
+ # Applied on a remote profile when TLS_ENABLED=false, which is the default and
5
+ # the only thing that works on an automatic domain: no public certificate
6
+ # authority issues a wildcard for sslip.io or nip.io, and neither offers the
7
+ # DNS-01 API that a wildcard needs.
8
+ #
9
+ # The point is what this does NOT do. The TLS overlay redirects :80 to :443
10
+ # permanently, and doing that without a certificate the browser accepts turns a
11
+ # URL that works into a warning page — the opposite of the goal. So a host with
12
+ # no TLS answers on :80 and says so.
13
+ #
14
+ # docker/compose/profiles/remote-tls.yaml replaces this once TLS is configured.
15
+ # ============================================================================
16
+
17
+ services:
18
+ traefik:
19
+ environment:
20
+ # Routers that name no entrypoint attach to :80 only, exactly as they do
21
+ # on the local profile. Nothing answers on :443, rather than answering
22
+ # with a certificate nobody can verify.
23
+ TRAEFIK_ENTRYPOINTS_WEB_ASDEFAULT: "true"
@@ -0,0 +1,12 @@
1
+ # Operational images
2
+
3
+ These directories are self-contained build contexts for images used by the
4
+ Portta runtime rather than by a Compose service directly:
5
+
6
+ - `apply/` builds the opt-in applier that recreates the gateway after settings
7
+ change.
8
+ - `toolbox/` builds the pinned diagnostic and database-client toolbox.
9
+
10
+ Application images stay with their applications. In particular,
11
+ `apps/web/Dockerfile` remains under `apps/web` because it builds the web and
12
+ authentication workspaces from the repository-root context.
@@ -0,0 +1,45 @@
1
+ # ============================================================================
2
+ # Portta applier
3
+ # ============================================================================
4
+ # The one image that may drive Compose on the host, so that a settings change
5
+ # saved in the panel can be applied without a terminal.
6
+ #
7
+ # It exists because Traefik reads its static configuration from the environment
8
+ # its container was created with (docs/development/adr/0003-traefik-static-config-via-env.md):
9
+ # a saved setting needs the containers *recreated*, and recreating them means
10
+ # Compose, which the panel deliberately cannot reach
11
+ # (docs/development/adr/0008-web-panel-socket-proxy.md).
12
+ #
13
+ # Built on demand and cached locally, like the toolbox; never published. Off
14
+ # unless PORTTA_APPLY=true. See docs/development/adr/0026-applying-settings-from-the-panel.md.
15
+ #
16
+ # Node runs the packaged Portta CLI; Bash remains only for the closed project
17
+ # runner command.
18
+ #
19
+ # buildx, because `up` passes --build on a repository checkout. Nothing is built
20
+ # in here: the context is streamed over the mounted socket and the host daemon
21
+ # does the work, with the host's network and layer cache. The plugin is only how
22
+ # the compose CLI reaches that daemon, which is why --network none is no
23
+ # obstacle -- and why this image needs no toolchain of its own.
24
+ # ============================================================================
25
+ FROM alpine:3.24.1
26
+
27
+ ARG PORTTA_VERSION
28
+
29
+ # Pinned base, unpinned packages: Alpine's repositories only carry the current
30
+ # patch level for a release, so `apk add` inside 3.24.1 is already constrained.
31
+ RUN apk add --no-cache \
32
+ bash \
33
+ nodejs \
34
+ docker-cli \
35
+ docker-cli-buildx \
36
+ docker-cli-compose
37
+
38
+ LABEL org.opencontainers.image.title="portta-apply" \
39
+ org.opencontainers.image.description="Recreates Portta's own containers on the host" \
40
+ org.opencontainers.image.version="$PORTTA_VERSION" \
41
+ org.opencontainers.image.source="https://github.com/fabioassuncao/portta" \
42
+ org.opencontainers.image.licenses="MIT"
43
+
44
+ ENTRYPOINT []
45
+ CMD ["/bin/bash"]
@@ -0,0 +1,38 @@
1
+ # ============================================================================
2
+ # Portta toolbox
3
+ # ============================================================================
4
+ # One small image holding the diagnostic and client tools the gateway needs, so
5
+ # a host only ever has to provide Docker, Git and a shell.
6
+ #
7
+ # Built on demand and cached locally; see `portta toolbox build`.
8
+ # ============================================================================
9
+ FROM alpine:3.24.1
10
+
11
+ ARG PORTTA_VERSION
12
+
13
+ # Pinned base, unpinned packages: Alpine's repositories only carry the current
14
+ # patch level for a release, so `apk add` inside 3.24.1 is already constrained.
15
+ RUN apk add --no-cache \
16
+ bash \
17
+ ca-certificates \
18
+ curl \
19
+ jq \
20
+ bind-tools \
21
+ openssl \
22
+ socat \
23
+ netcat-openbsd \
24
+ openssh-client \
25
+ postgresql-client \
26
+ mysql-client \
27
+ redis \
28
+ iputils \
29
+ && update-ca-certificates
30
+
31
+ LABEL org.opencontainers.image.title="portta-toolbox" \
32
+ org.opencontainers.image.description="Diagnostic and client tools for Portta" \
33
+ org.opencontainers.image.version="$PORTTA_VERSION" \
34
+ org.opencontainers.image.source="https://github.com/fabioassuncao/portta" \
35
+ org.opencontainers.image.licenses="MIT"
36
+
37
+ ENTRYPOINT []
38
+ CMD ["/bin/bash"]