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,189 @@
1
+ #!/usr/bin/env bash
2
+ # The command the project runner container is created with. Fixed at creation;
3
+ # the panel supplies no argument. The request is { verb, project, flags } in
4
+ # state/runner/request.json, plus { workingDir, configFiles } for an `up` of a
5
+ # project that has no container left. See docs/development/adr/0030-the-panel-and-a-project-lifecycle.md.
6
+ set -euo pipefail
7
+
8
+ PORTTA_ROOT="${PORTTA_ROOT:?PORTTA_ROOT is required}"
9
+ REQUEST="$PORTTA_ROOT/state/runner/request.json"
10
+ HOST_ROOT=/host
11
+
12
+ die() { printf 'error: %s\n' "$1" >&2; exit 1; }
13
+
14
+ [ -f "$REQUEST" ] || die "no runner request at $REQUEST"
15
+
16
+ # Closed fields, closed values. A full JSON parser is not needed and would be a
17
+ # dependency this image does not have. The panel refuses a path carrying a
18
+ # comma, a quote or a backslash before it writes the request, which is what
19
+ # lets the list be read as text between the brackets.
20
+ verb=$(sed -n 's/.*"verb"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$REQUEST" | head -n1)
21
+ project=$(sed -n 's/.*"project"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$REQUEST" | head -n1)
22
+ nocache=$(grep -q '"no-cache"' "$REQUEST" && printf '1' || printf '0')
23
+ directory=$(grep -q '"directory"' "$REQUEST" && printf '1' || printf '0')
24
+ request_working_dir=$(sed -n 's/.*"workingDir"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$REQUEST" | head -n1)
25
+ request_config_files=$(sed -n 's/.*"configFiles"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p' "$REQUEST" | head -n1 | tr -d '"')
26
+
27
+ case "$verb" in
28
+ up|stop|restart|build|down|down-volumes) ;;
29
+ *) die "unknown runner verb '$verb'" ;;
30
+ esac
31
+
32
+ [[ "$project" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || die "refusing project name '$project'"
33
+
34
+ # The bound a path from the request must satisfy, the same one
35
+ # packages/core/src/runner.ts checks before writing it: absolute, no walk-up,
36
+ # not `/`, not a top-level directory.
37
+ assert_request_path() {
38
+ local path="$1" what="$2"
39
+ [[ "$path" == /* ]] || die "$what is not absolute"
40
+ [[ "$path" != *..* ]] || die "refusing $what that walks up"
41
+ [[ "$path" != "/" ]] || die "refusing / as $what"
42
+ case "${path#/}" in
43
+ */*) ;;
44
+ *) die "refusing a top-level directory as $what" ;;
45
+ esac
46
+ }
47
+
48
+ # With no container left there is no managed label to protect Portta itself.
49
+ # Refuse both its root and descendants: a remembered request must never run a
50
+ # consumer Compose file from the gateway checkout.
51
+ assert_not_portta_path() {
52
+ local path="$1" what="$2"
53
+ case "$path" in
54
+ "$PORTTA_ROOT"|"$PORTTA_ROOT"/*) die "refusing Portta's own directory as $what" ;;
55
+ esac
56
+ }
57
+
58
+ id=$(docker ps -aq --filter "label=com.docker.compose.project=${project}" | head -n1)
59
+ if [ -n "$id" ]; then
60
+ # A container exists: its labels are the truth, whatever the request says.
61
+ managed=$(docker inspect "$id" --format '{{ index .Config.Labels "portta.managed" }}' 2>/dev/null || true)
62
+ [ "$managed" != "true" ] || die "refusing to operate Portta's own project"
63
+ working_dir=$(docker inspect "$id" --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}')
64
+ config_files=$(docker inspect "$id" --format '{{ index .Config.Labels "com.docker.compose.project.config_files" }}')
65
+ else
66
+ # Nothing left to read labels from: only `up` makes sense, and only with the
67
+ # paths the panel remembered. Portta's own project is refused by name and by
68
+ # directory, since no label can say so here.
69
+ [ "$verb" = "up" ] || die "no container on this host belongs to project '$project'"
70
+ [ -n "$request_working_dir" ] || die "no container on this host belongs to project '$project', and the request names no working directory"
71
+ own_name=$(sed -n 's/^PORTTA_PROJECT_NAME=//p' "$PORTTA_ROOT/.env" 2>/dev/null | head -n1)
72
+ own_name="${own_name%\"}"; own_name="${own_name#\"}"; own_name="${own_name%\'}"; own_name="${own_name#\'}"
73
+ own_name="${own_name:-portta}"
74
+ [ "$project" != "$own_name" ] || die "refusing to operate Portta's own project"
75
+ assert_request_path "$request_working_dir" "working directory"
76
+ assert_not_portta_path "$request_working_dir" "a working directory"
77
+ working_dir="$request_working_dir"
78
+ config_files="$request_config_files"
79
+ fi
80
+
81
+ [ -n "$working_dir" ] || die "project '$project' has no Compose working directory label"
82
+ [ -d "${HOST_ROOT}${working_dir}" ] || die "working directory ${working_dir} does not exist on this host"
83
+
84
+ # Compose sees the host filesystem at the host's own paths, so `include:` and
85
+ # every relative path in a Compose file resolve exactly as they do on the host.
86
+ # The first ancestor of a path that does not exist in this container becomes a
87
+ # symlink into /host; the repository root is already mounted at its own path.
88
+ link_host_path() {
89
+ local target="$1" partial="" rest segment
90
+ case "$target" in
91
+ "$PORTTA_ROOT"|"$PORTTA_ROOT"/*) return 0 ;;
92
+ esac
93
+ rest="${target#/}"
94
+ while [ -n "$rest" ]; do
95
+ segment="${rest%%/*}"
96
+ if [ "$segment" = "$rest" ]; then rest=""; else rest="${rest#*/}"; fi
97
+ partial="$partial/$segment"
98
+ if [ -L "$partial" ]; then
99
+ [ "$(realpath "$partial")" = "$(realpath "${HOST_ROOT}${partial}")" ] || die "${partial} in the runner does not point at the host's ${partial}; recreate the runner with portta up"
100
+ return 0
101
+ fi
102
+ if [ ! -e "$partial" ]; then
103
+ ln -s "${HOST_ROOT}${partial}" "$partial"
104
+ return 0
105
+ fi
106
+ done
107
+ [ "$(realpath "$target")" = "$(realpath "${HOST_ROOT}${target}")" ] || die "${target} exists inside the runner image and is not the host's ${target}"
108
+ }
109
+
110
+ link_host_path "$working_dir"
111
+
112
+ # Compose files as the daemon recorded them, or as the panel remembered them.
113
+ # Readability is checked through /host; the path handed to Compose is the
114
+ # host's, like --project-directory, so bind mounts resolve on the host.
115
+ files=()
116
+ if [ -n "$config_files" ]; then
117
+ IFS=',' read -r -a listed <<< "$config_files"
118
+ for file in "${listed[@]}"; do
119
+ file="${file#"${file%%[![:space:]]*}"}"
120
+ file="${file%"${file##*[![:space:]]}"}"
121
+ [ -n "$file" ] || continue
122
+ [ -z "$id" ] && assert_request_path "$file" "compose file"
123
+ [ -n "$id" ] || assert_not_portta_path "$file" "a compose file"
124
+ [ -f "${HOST_ROOT}${file}" ] || die "compose file ${file} is not readable"
125
+ case "$file" in
126
+ "$working_dir"/*) ;;
127
+ *) link_host_path "${file%/*}" ;;
128
+ esac
129
+ files+=(-f "$file")
130
+ done
131
+ fi
132
+ if [ ${#files[@]} -eq 0 ]; then
133
+ for name in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
134
+ if [ -f "${HOST_ROOT}${working_dir}/${name}" ]; then
135
+ files+=(-f "${working_dir}/${name}")
136
+ break
137
+ fi
138
+ done
139
+ fi
140
+ [ ${#files[@]} -gt 0 ] || die "no compose file in ${working_dir}"
141
+
142
+ compose() {
143
+ docker compose --project-name "$project" --project-directory "$working_dir" "${files[@]}" "$@"
144
+ }
145
+
146
+ # The working directory is Docker's label. Removal is the same bound as
147
+ # packages/core/src/paths.ts: absolute, no walk-up, not `/`, not a top-level
148
+ # directory. `realpath` is the last check so a symlink cannot leave /host.
149
+ remove_working_dir() {
150
+ local dir="$1"
151
+ [[ "$dir" == /* ]] || die "working directory is not absolute"
152
+ [[ "$dir" != *..* ]] || die "refusing working directory that walks up"
153
+ [[ "$dir" != "/" ]] || die "refusing to remove /"
154
+ case "${dir#/}" in
155
+ */*) ;;
156
+ *) die "refusing to remove a top-level directory" ;;
157
+ esac
158
+ local target="${HOST_ROOT}${dir}"
159
+ [ -d "$target" ] || die "working directory ${dir} does not exist on this host"
160
+ local real
161
+ real=$(realpath "$target")
162
+ case "$real" in
163
+ /host/*) ;;
164
+ *) die "resolved path is outside /host" ;;
165
+ esac
166
+ [ "$real" != "/host" ] && [ "$real" != "/" ] || die "refusing to remove the host root"
167
+ rm -rf -- "$real"
168
+ }
169
+
170
+ case "$verb" in
171
+ up) compose up -d --remove-orphans ;;
172
+ stop) compose stop ;;
173
+ restart) compose stop && compose up -d --remove-orphans ;;
174
+ build)
175
+ if [ "$nocache" = "1" ]; then
176
+ compose build --no-cache
177
+ else
178
+ compose build
179
+ fi
180
+ compose up -d --remove-orphans
181
+ ;;
182
+ down) compose down ;;
183
+ down-volumes)
184
+ compose down --volumes
185
+ if [ "$directory" = "1" ]; then
186
+ remove_working_dir "$working_dir"
187
+ fi
188
+ ;;
189
+ esac
@@ -0,0 +1,43 @@
1
+ # Templates
2
+
3
+ Reference overlays for the shapes a project usually has. They are **references
4
+ to copy from**, not runtime that projects depend on: nothing here is included,
5
+ mounted or extended by a consumer project. Copy the closest one, adjust the
6
+ service names and ports, and it is yours.
7
+
8
+ `portta env init <path>` generates the same thing from a project's actual
9
+ Compose file, which is usually faster.
10
+
11
+ | Template | Shape |
12
+ |---|---|
13
+ | [`01-single-web.yaml`](overlays/01-single-web.yaml) | one HTTP service |
14
+ | [`02-web-api.yaml`](overlays/02-web-api.yaml) | web + API |
15
+ | [`03-web-api-postgres.yaml`](overlays/03-web-api-postgres.yaml) | web + API + a database that stays private |
16
+ | [`04-web-api-postgres-redis.yaml`](overlays/04-web-api-postgres-redis.yaml) | the common full stack |
17
+ | [`05-multiple-apis.yaml`](overlays/05-multiple-apis.yaml) | several APIs behind one namespace |
18
+ | [`06-monorepo.yaml`](overlays/06-monorepo.yaml) | `apps/` and `services/` in one repository |
19
+ | [`07-worktree.env`](overlays/07-worktree.env) | a second copy of a project, in parallel |
20
+ | [`08-nonstandard-port.yaml`](overlays/08-nonstandard-port.yaml) | an HTTP service on an unusual internal port |
21
+ | [`09-tcp-routing.yaml`](overlays/09-tcp-routing.yaml) | opt a datastore into hostname TCP routing |
22
+ | [`10-mailpit.yaml`](overlays/10-mailpit.yaml) | Mailpit UI on the gateway (SMTP stays private) |
23
+ | [`11-rustfs.yaml`](overlays/11-rustfs.yaml) | RustFS console on the gateway (S3 API stays private) |
24
+
25
+ ## The two rules every template follows
26
+
27
+ **Labels are in list form.** Compose interpolates `${VAR}` inside a list entry
28
+ but **not** inside a mapping key. Written as a map, the Traefik service name
29
+ keeps the literal `${COMPOSE_PROJECT_NAME}` and every worktree of the project
30
+ collapses onto one load balancer.
31
+
32
+ **Traefik service names carry the namespace.** Those names are flat across the
33
+ whole host. Two projects declaring a bare `web` are merged into a single load
34
+ balancer and start serving each other's traffic.
35
+
36
+ `portta doctor` reports both.
37
+
38
+ ## For the consumer repository
39
+
40
+ [`project/PORTTA.md`](project/PORTTA.md) is a short page to copy into
41
+ the project itself: how to start it, its URLs, how to reach its database, how
42
+ to run a second copy. It deliberately does not restate the gateway's
43
+ documentation; this repository stays the single source of the rules.
@@ -0,0 +1,23 @@
1
+ # ============================================================================
2
+ # One HTTP service
3
+ # ============================================================================
4
+ # docker compose -f compose.yaml -f compose.portta.yaml up -d
5
+ # -> http://<COMPOSE_PROJECT_NAME>-web.localhost
6
+ # ============================================================================
7
+
8
+ services:
9
+ web:
10
+ networks:
11
+ - default
12
+ - portta
13
+ labels:
14
+ - "traefik.enable=true"
15
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
16
+ # Only needed when the image's EXPOSE does not match the port the app
17
+ # actually listens on. It usually does not, so state it.
18
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=3000"
19
+
20
+ networks:
21
+ portta:
22
+ external: true
23
+ name: ${PORTTA_NETWORK:-portta}
@@ -0,0 +1,30 @@
1
+ # ============================================================================
2
+ # Web + API
3
+ # ============================================================================
4
+ # Both keep their internal ports. Other projects on this host use the same
5
+ # 3000 and 8000 at the same time, and nothing collides, because neither
6
+ # publishes a host port.
7
+ #
8
+ # -> http://<COMPOSE_PROJECT_NAME>-web.localhost
9
+ # -> http://<COMPOSE_PROJECT_NAME>-api.localhost
10
+ # ============================================================================
11
+
12
+ services:
13
+ web:
14
+ networks: [default, portta]
15
+ labels:
16
+ - "traefik.enable=true"
17
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
18
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=3000"
19
+
20
+ api:
21
+ networks: [default, portta]
22
+ labels:
23
+ - "traefik.enable=true"
24
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
25
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-api.loadbalancer.server.port=8000"
26
+
27
+ networks:
28
+ portta:
29
+ external: true
30
+ name: ${PORTTA_NETWORK:-portta}
@@ -0,0 +1,33 @@
1
+ # ============================================================================
2
+ # Web + API + PostgreSQL
3
+ # ============================================================================
4
+ # Note what is NOT here: postgres. A database has no business on the shared
5
+ # network, since everything else on the host could reach it, and it does not
6
+ # need a host port either.
7
+ #
8
+ # The API reaches it as `postgres:5432` over the project's own network, exactly
9
+ # as before. A human reaches it on demand:
10
+ #
11
+ # portta access open --project <name> --service postgres
12
+ # -> 127.0.0.1:<free port>
13
+ # ============================================================================
14
+
15
+ services:
16
+ web:
17
+ networks: [default, portta]
18
+ labels:
19
+ - "traefik.enable=true"
20
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
21
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=3000"
22
+
23
+ api:
24
+ networks: [default, portta]
25
+ labels:
26
+ - "traefik.enable=true"
27
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
28
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-api.loadbalancer.server.port=8000"
29
+
30
+ networks:
31
+ portta:
32
+ external: true
33
+ name: ${PORTTA_NETWORK:-portta}
@@ -0,0 +1,30 @@
1
+ # ============================================================================
2
+ # Web + API + PostgreSQL + Redis: the common full stack
3
+ # ============================================================================
4
+ # Identical to template 03: adding a cache changes nothing about the contract.
5
+ # Postgres and Redis both stay on the project's private network, both keep
6
+ # their standard ports, and both are reached with `portta access open`.
7
+ #
8
+ # Workers are absent too. A queue consumer serves no HTTP, so it has nothing to
9
+ # route; it stays on `default` and needs no changes at all.
10
+ # ============================================================================
11
+
12
+ services:
13
+ web:
14
+ networks: [default, portta]
15
+ labels:
16
+ - "traefik.enable=true"
17
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
18
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=3000"
19
+
20
+ api:
21
+ networks: [default, portta]
22
+ labels:
23
+ - "traefik.enable=true"
24
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
25
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-api.loadbalancer.server.port=8000"
26
+
27
+ networks:
28
+ portta:
29
+ external: true
30
+ name: ${PORTTA_NETWORK:-portta}
@@ -0,0 +1,37 @@
1
+ # ============================================================================
2
+ # Several APIs in one project
3
+ # ============================================================================
4
+ # Each gets its own hostname from its own service name. They can all listen on
5
+ # 8000 internally; nothing needs renumbering.
6
+ #
7
+ # -> http://<COMPOSE_PROJECT_NAME>-api-auth.localhost
8
+ # -> http://<COMPOSE_PROJECT_NAME>-api-billing.localhost
9
+ # -> http://<COMPOSE_PROJECT_NAME>-api-search.localhost
10
+ # ============================================================================
11
+
12
+ services:
13
+ api-auth:
14
+ networks: [default, portta]
15
+ labels:
16
+ - "traefik.enable=true"
17
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
18
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-api-auth.loadbalancer.server.port=8000"
19
+
20
+ api-billing:
21
+ networks: [default, portta]
22
+ labels:
23
+ - "traefik.enable=true"
24
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
25
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-api-billing.loadbalancer.server.port=8000"
26
+
27
+ api-search:
28
+ networks: [default, portta]
29
+ labels:
30
+ - "traefik.enable=true"
31
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
32
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-api-search.loadbalancer.server.port=8000"
33
+
34
+ networks:
35
+ portta:
36
+ external: true
37
+ name: ${PORTTA_NETWORK:-portta}
@@ -0,0 +1,63 @@
1
+ # ============================================================================
2
+ # Monorepo: apps/ and services/ in one repository
3
+ # ============================================================================
4
+ # A monorepo is one Compose project with more services in it. Nothing about the
5
+ # contract changes, and the gateway needs to know nothing about the layout.
6
+ #
7
+ # repo/
8
+ # apps/web/ -> web
9
+ # apps/admin/ -> admin
10
+ # services/api/ -> api
11
+ # services/worker/ -> worker (no HTTP; not routed)
12
+ # services/importer/ -> importer (no HTTP; not routed)
13
+ # compose.yaml
14
+ # compose.portta.yaml <- this file
15
+ #
16
+ # -> http://<COMPOSE_PROJECT_NAME>-web.localhost
17
+ # -> http://<COMPOSE_PROJECT_NAME>-admin.localhost
18
+ # -> http://<COMPOSE_PROJECT_NAME>-api.localhost
19
+ #
20
+ # Build contexts, Dockerfiles and volumes stay exactly where they are. The
21
+ # gateway never centralises any of that.
22
+ #
23
+ # Splitting one monorepo into several namespaces is possible: run separate
24
+ # Compose projects per app: but it is rarely worth it: one namespace keeps the
25
+ # services on a single private network, which is usually what they expect.
26
+ # ============================================================================
27
+
28
+ services:
29
+ web:
30
+ networks: [default, portta]
31
+ labels:
32
+ # Optional, and only useful because inference cannot get them right here:
33
+ # `portta.git.root` says where the repository actually starts when
34
+ # the Compose file is not at its root, and `portta.repo` gives the
35
+ # panel a repository link with no host-side Git at all. Delete both and
36
+ # everything behaves exactly as it does now.
37
+ # - "portta.repo=owner/monorepo"
38
+ # - "portta.git.root=/srv/dev/monorepo"
39
+ - "traefik.enable=true"
40
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
41
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=3000"
42
+
43
+ admin:
44
+ networks: [default, portta]
45
+ labels:
46
+ - "traefik.enable=true"
47
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
48
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-admin.loadbalancer.server.port=3000"
49
+
50
+ api:
51
+ networks: [default, portta]
52
+ labels:
53
+ - "traefik.enable=true"
54
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
55
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-api.loadbalancer.server.port=8000"
56
+
57
+ # worker and importer are absent on purpose: they serve no HTTP, so they need
58
+ # no networks and no labels. They keep running exactly as they did.
59
+
60
+ networks:
61
+ portta:
62
+ external: true
63
+ name: ${PORTTA_NETWORK:-portta}
@@ -0,0 +1,47 @@
1
+ # ============================================================================
2
+ # A second copy of a project, running in parallel
3
+ # ============================================================================
4
+ # Copy to `.env` in the worktree. This is the entire mechanism: Compose derives
5
+ # container, network and volume names from COMPOSE_PROJECT_NAME, and the gateway
6
+ # derives hostnames from it.
7
+ #
8
+ # git worktree add ../base-empresarial-issue59 issue59
9
+ # cd ../base-empresarial-issue59
10
+ # cp <this file> .env
11
+ # docker compose -f compose.yaml -f compose.portta.yaml up -d
12
+ #
13
+ # -> http://base-empresarial-issue59-web.localhost
14
+ #
15
+ # The original environment keeps running. Nothing inside the project changed.
16
+ # Derive the name instead of inventing one:
17
+ #
18
+ # portta env namespace
19
+ # ============================================================================
20
+
21
+ COMPOSE_PROJECT_NAME=base-empresarial-issue59
22
+
23
+ # Saves repeating -f on every command.
24
+ COMPOSE_FILE=compose.yaml:compose.portta.yaml
25
+
26
+ # Must match PORTTA_NETWORK in the gateway's own .env.
27
+ PORTTA_NETWORK=portta
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Optional: say which project this worktree belongs to
31
+ # ---------------------------------------------------------------------------
32
+ # COMPOSE_PROJECT_NAME is a per-worktree namespace, so five worktrees look like
33
+ # five projects in the panel. A `portta.project` label on any one service
34
+ # groups them back under one heading. Nothing depends on it: without it the
35
+ # panel still infers the worktree from the directory name, exactly as before.
36
+ #
37
+ # labels:
38
+ # - "portta.project=base-empresarial"
39
+ # - "portta.repo=owner/base-empresarial"
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # What must NOT be shared with the other worktree
43
+ # ---------------------------------------------------------------------------
44
+ # Do not point this environment at an external volume or a database the other
45
+ # copy also uses. Two worktrees writing to one database is the failure this
46
+ # whole mechanism exists to prevent, and it is silent until it corrupts
47
+ # something. Let Compose create per-namespace volumes.
@@ -0,0 +1,41 @@
1
+ # ============================================================================
2
+ # An HTTP service on an unusual internal port
3
+ # ============================================================================
4
+ # Traefik guesses the backend port from the image's EXPOSE when only one port
5
+ # is exposed. That guess is wrong whenever the image exposes one port and the
6
+ # application listens on another: which is most of the time once a command or
7
+ # an environment variable moves it.
8
+ #
9
+ # The symptom is a 502 from the gateway: the route matched, the backend did not
10
+ # answer. Stating the port removes the guess.
11
+ #
12
+ # Anything is fine here: 4321, 9090, 1313. The port is internal, so it never
13
+ # competes with another project.
14
+ # ============================================================================
15
+
16
+ services:
17
+ docs:
18
+ # e.g. a static site generator listening on 1313
19
+ networks: [default, portta]
20
+ labels:
21
+ - "traefik.enable=true"
22
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
23
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-docs.loadbalancer.server.port=1313"
24
+
25
+ # A service whose container serves HTTPS internally needs the scheme too,
26
+ # or Traefik will speak plaintext to a TLS port.
27
+ secure-backend:
28
+ networks: [default, portta]
29
+ labels:
30
+ - "traefik.enable=true"
31
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
32
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-secure-backend.loadbalancer.server.port=8443"
33
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-secure-backend.loadbalancer.server.scheme=https"
34
+ # Development backends usually have a self-signed certificate.
35
+ - "traefik.http.serverstransports.${COMPOSE_PROJECT_NAME}-insecure.insecureskipverify=true"
36
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-secure-backend.loadbalancer.serverstransport=${COMPOSE_PROJECT_NAME}-insecure@docker"
37
+
38
+ networks:
39
+ portta:
40
+ external: true
41
+ name: ${PORTTA_NETWORK:-portta}
@@ -0,0 +1,70 @@
1
+ # ============================================================================
2
+ # Template 09: reach this project's database by hostname
3
+ # ============================================================================
4
+ # Add this on top of your project's own compose file, alongside whichever HTTP
5
+ # overlay you already use. It opts a datastore into hostname routing, so it is
6
+ # reachable on the gateway's shared port without publishing one of its own:
7
+ #
8
+ # psql "postgresql://user@<project>-postgres.<domain>:5432/db?sslmode=require"
9
+ #
10
+ # Requires PORTTA_TCP=true on the gateway. Read docs/product/guides/tcp-routing.md first:
11
+ # PostgreSQL and Redis work, MySQL cannot, and TLS is not optional.
12
+ #
13
+ # docker compose -f compose.yaml -f compose.portta.yaml \
14
+ # -f compose.portta-tcp.yaml up -d
15
+ #
16
+ # Three details worth copying verbatim:
17
+ #
18
+ # 1. The datastore joins `portta-access`, NOT the shared `portta`
19
+ # network. Databases have never been on the HTTP network and still are not.
20
+ #
21
+ # 2. The rule is HostSNIRegexp, not HostSNI, so this file does not have to know
22
+ # the gateway's domain. The same overlay then works unchanged against
23
+ # `.localhost` on a workstation and `.vpn.example.com` on a VPS. It grants
24
+ # nothing extra: whoever knows the project name can reach the entrypoint
25
+ # with the exact hostname anyway, and authentication is still PostgreSQL's.
26
+ #
27
+ # 3. Router and service names carry the project name, because they share one
28
+ # namespace across every project on the host. `portta doctor` reports
29
+ # a collision if two projects ever pick the same one.
30
+ # ============================================================================
31
+
32
+ services:
33
+ postgres:
34
+ networks:
35
+ - default # keep talking to the rest of the project privately
36
+ - portta-access
37
+ labels:
38
+ - "traefik.enable=true"
39
+ - "traefik.docker.network=${PORTTA_ACCESS_NETWORK:-portta-access}"
40
+ - "traefik.tcp.routers.${COMPOSE_PROJECT_NAME}-postgres.entrypoints=postgres"
41
+ - "traefik.tcp.routers.${COMPOSE_PROJECT_NAME}-postgres.rule=HostSNIRegexp(`^${COMPOSE_PROJECT_NAME}-postgres\\..+$`)"
42
+ - "traefik.tcp.routers.${COMPOSE_PROJECT_NAME}-postgres.tls=true"
43
+ # libpq 17+ offers ALPN `postgresql`; without this option the TLS
44
+ # handshake is rejected before any Postgres traffic happens.
45
+ - "traefik.tcp.routers.${COMPOSE_PROJECT_NAME}-postgres.tls.options=postgres@file"
46
+ - "traefik.tcp.routers.${COMPOSE_PROJECT_NAME}-postgres.service=${COMPOSE_PROJECT_NAME}-postgres"
47
+ - "traefik.tcp.services.${COMPOSE_PROJECT_NAME}-postgres.loadbalancer.server.port=5432"
48
+
49
+ redis:
50
+ networks:
51
+ - default
52
+ - portta-access
53
+ labels:
54
+ - "traefik.enable=true"
55
+ - "traefik.docker.network=${PORTTA_ACCESS_NETWORK:-portta-access}"
56
+ - "traefik.tcp.routers.${COMPOSE_PROJECT_NAME}-redis.entrypoints=redis"
57
+ - "traefik.tcp.routers.${COMPOSE_PROJECT_NAME}-redis.rule=HostSNIRegexp(`^${COMPOSE_PROJECT_NAME}-redis\\..+$`)"
58
+ - "traefik.tcp.routers.${COMPOSE_PROJECT_NAME}-redis.tls=true"
59
+ - "traefik.tcp.routers.${COMPOSE_PROJECT_NAME}-redis.service=${COMPOSE_PROJECT_NAME}-redis"
60
+ - "traefik.tcp.services.${COMPOSE_PROJECT_NAME}-redis.loadbalancer.server.port=6379"
61
+
62
+ # MySQL and MariaDB are deliberately absent. The MySQL protocol has the
63
+ # server speak first, so there is no SNI for the gateway to route on before
64
+ # it must choose a backend. Reach them with `portta access open`, which
65
+ # works for every protocol. docs/product/guides/tcp-routing.md explains why in full.
66
+
67
+ networks:
68
+ portta-access:
69
+ external: true
70
+ name: ${PORTTA_ACCESS_NETWORK:-portta-access}
@@ -0,0 +1,24 @@
1
+ # ============================================================================
2
+ # Mailpit: capture outbound mail in development
3
+ # ============================================================================
4
+ # The UI (8025) is HTTP and joins the gateway. SMTP (1025) stays on the
5
+ # project's private network and is reached with `portta access open`.
6
+ #
7
+ # The base Compose file should declare the mailpit service itself; this overlay
8
+ # only adds the network and labels.
9
+ #
10
+ # -> http://<COMPOSE_PROJECT_NAME>-mailpit.localhost
11
+ # ============================================================================
12
+
13
+ services:
14
+ mailpit:
15
+ networks: [default, portta]
16
+ labels:
17
+ - "traefik.enable=true"
18
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
19
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-mailpit.loadbalancer.server.port=8025"
20
+
21
+ networks:
22
+ portta:
23
+ external: true
24
+ name: ${PORTTA_NETWORK:-portta}
@@ -0,0 +1,25 @@
1
+ # ============================================================================
2
+ # RustFS: S3-compatible object storage in development
3
+ # ============================================================================
4
+ # The console (9001) is HTTP and joins the gateway. The S3 API (9000) stays on
5
+ # the project's private network so sibling containers can talk to it as
6
+ # `rustfs:9000` without publishing a host port.
7
+ #
8
+ # The base Compose file should declare the rustfs service itself; this overlay
9
+ # only adds the network and labels for the console.
10
+ #
11
+ # -> http://<COMPOSE_PROJECT_NAME>-rustfs.localhost
12
+ # ============================================================================
13
+
14
+ services:
15
+ rustfs:
16
+ networks: [default, portta]
17
+ labels:
18
+ - "traefik.enable=true"
19
+ - "traefik.docker.network=${PORTTA_NETWORK:-portta}"
20
+ - "traefik.http.services.${COMPOSE_PROJECT_NAME}-rustfs.loadbalancer.server.port=9001"
21
+
22
+ networks:
23
+ portta:
24
+ external: true
25
+ name: ${PORTTA_NETWORK:-portta}