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.
- package/dist/cli.js +77013 -0
- package/dist/documentation.json +1 -0
- package/dist/runtime/.env.example +440 -0
- package/dist/runtime/VERSION +1 -0
- package/dist/runtime/config/traefik/dynamic/auth.example.yaml.disabled +24 -0
- package/dist/runtime/config/traefik/dynamic/local-tls.yaml +11 -0
- package/dist/runtime/config/traefik/dynamic/middlewares.yaml +35 -0
- package/dist/runtime/config/traefik/dynamic/portta-aliases.yaml +9 -0
- package/dist/runtime/config/traefik/dynamic/portta-auth.yaml +19 -0
- package/dist/runtime/config/traefik/dynamic/portta-shares.yaml +10 -0
- package/dist/runtime/config/traefik/dynamic/tcp.yaml +23 -0
- package/dist/runtime/docker/compose/attach/host.yaml +24 -0
- package/dist/runtime/docker/compose/attach/tailscale.yaml +70 -0
- package/dist/runtime/docker/compose/compose.yaml +214 -0
- package/dist/runtime/docker/compose/features/auth-build.yaml +19 -0
- package/dist/runtime/docker/compose/features/auth-dev.yaml +20 -0
- package/dist/runtime/docker/compose/features/cloudflare-tunnel.yaml +69 -0
- package/dist/runtime/docker/compose/features/dashboard-tailscale.yaml +21 -0
- package/dist/runtime/docker/compose/features/dashboard.yaml +21 -0
- package/dist/runtime/docker/compose/features/db.yaml +63 -0
- package/dist/runtime/docker/compose/features/panel-domain.yaml +42 -0
- package/dist/runtime/docker/compose/features/panel-public.yaml +47 -0
- package/dist/runtime/docker/compose/features/panel-webhook.yaml +46 -0
- package/dist/runtime/docker/compose/features/tcp-tailscale.yaml +30 -0
- package/dist/runtime/docker/compose/features/tcp.yaml +40 -0
- package/dist/runtime/docker/compose/features/web-bind.yaml +19 -0
- package/dist/runtime/docker/compose/features/web-build.yaml +21 -0
- package/dist/runtime/docker/compose/features/web-dev.yaml +53 -0
- package/dist/runtime/docker/compose/features/web-vpn.yaml +24 -0
- package/dist/runtime/docker/compose/features/web.yaml +283 -0
- package/dist/runtime/docker/compose/profiles/local-tls.yaml +23 -0
- package/dist/runtime/docker/compose/profiles/local.yaml +15 -0
- package/dist/runtime/docker/compose/profiles/public.yaml +19 -0
- package/dist/runtime/docker/compose/profiles/remote-tls-dns.yaml +26 -0
- package/dist/runtime/docker/compose/profiles/remote-tls-http.yaml +32 -0
- package/dist/runtime/docker/compose/profiles/remote-tls.yaml +31 -0
- package/dist/runtime/docker/compose/profiles/remote.yaml +23 -0
- package/dist/runtime/docker/images/README.md +12 -0
- package/dist/runtime/docker/images/apply/Dockerfile +45 -0
- package/dist/runtime/docker/images/toolbox/Dockerfile +38 -0
- package/dist/runtime/scripts/lib/runner-exec.sh +189 -0
- package/dist/runtime/templates/README.md +43 -0
- package/dist/runtime/templates/overlays/01-single-web.yaml +23 -0
- package/dist/runtime/templates/overlays/02-web-api.yaml +30 -0
- package/dist/runtime/templates/overlays/03-web-api-postgres.yaml +33 -0
- package/dist/runtime/templates/overlays/04-web-api-postgres-redis.yaml +30 -0
- package/dist/runtime/templates/overlays/05-multiple-apis.yaml +37 -0
- package/dist/runtime/templates/overlays/06-monorepo.yaml +63 -0
- package/dist/runtime/templates/overlays/07-worktree.env +47 -0
- package/dist/runtime/templates/overlays/08-nonstandard-port.yaml +41 -0
- package/dist/runtime/templates/overlays/09-tcp-routing.yaml +70 -0
- package/dist/runtime/templates/overlays/10-mailpit.yaml +24 -0
- package/dist/runtime/templates/overlays/11-rustfs.yaml +25 -0
- package/dist/runtime/templates/project/PORTTA.md +107 -0
- 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}
|