initial commit

This commit is contained in:
Worph
2026-10-03 16:09:47 +02:00
commit b5224cb512
25 changed files with 1804 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
name: gitea
services:
# Gitea itself — git hosting, the Actions controller, and the OCI package
# registry, all in one process. SQLite: a personal forge never has the write
# concurrency that would justify a second container for Postgres.
gitea:
image: gitea/gitea:1.27.3-rootless
container_name: gitea
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 70
deploy:
resources:
limits:
memory: 1G
environment:
TZ: $TZ
# Every URL Gitea hands out — clone URLs, the registry host, and the
# server_url the CI runner passes to jobs — is derived from ROOT_URL, so
# it has to be the address that works from outside as well as inside.
GITEA__server__ROOT_URL: https://gitea-${APP_DOMAIN}/
GITEA__server__DOMAIN: gitea-${APP_DOMAIN}
GITEA__server__HTTP_PORT: "3000"
GITEA__server__PROTOCOL: http
# The address Gitea's own CLI uses to reach the running server's internal
# API. It defaults to localhost, which is right for the server itself and
# useless from the init container that runs
# `gitea actions generate-runner-token`. It has to be set here rather than
# there: the CLI reads it out of the shared app.ini, and only this
# container's entrypoint renders environment into app.ini.
GITEA__server__LOCAL_ROOT_URL: http://gitea:3000/
# Git over SSH would need a published host port of its own. Everything
# here — clone, push, CI, registry — goes over HTTPS instead.
GITEA__server__DISABLE_SSH: "true"
GITEA__server__START_SSH_SERVER: "false"
# Skips the first-run install wizard: every value it would ask for is set
# here, and the admin account is created by an init step below.
GITEA__security__INSTALL_LOCK: "true"
GITEA__database__DB_TYPE: sqlite3
GITEA__database__PATH: /var/lib/gitea/data/gitea.db
GITEA__service__DISABLE_REGISTRATION: "true"
GITEA__service__REQUIRE_SIGNIN_VIEW: "true"
GITEA__actions__ENABLED: "true"
# Where a bare `uses: actions/checkout@v4` is resolved from.
GITEA__actions__DEFAULT_ACTIONS_URL: github
GITEA__packages__ENABLED: "true"
# Lets a pull mirror name a repository on this server's own Docker
# network — a Radicle node's git endpoint, for instance. See rationale.md.
GITEA__migrations__ALLOW_LOCALNETWORKS: "true"
# How often a pull mirror is re-fetched. The sync emits the same push
# event a real push would, so this interval is also how quickly CI
# reacts to a commit that arrives from the mirrored source.
GITEA__mirror__DEFAULT_INTERVAL: 10m
GITEA__mirror__MIN_INTERVAL: 10m
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/data:/var/lib/gitea
- ${DATA_ROOT:-/DATA}/AppData/$AppID/config:/etc/gitea
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/healthz"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
expose:
- 3000
labels:
caddy_0: gitea-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 3000}}"
caddy_1: gitea-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 3000}}"
caddy_2: gitea-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 3000}}"
networks:
- pcs
- gitea-internal
# The CI runner: act_runner with its own Docker daemon inside it (dind), so
# workflow jobs and the images they build never touch the host's daemon.
# See rationale.md for what `privileged` buys and what it costs.
gitea-runner:
image: gitea/act_runner:0.6.1-dind
container_name: gitea-runner
restart: unless-stopped
privileged: true
cpu_shares: 20
deploy:
resources:
limits:
memory: 2G
environment:
TZ: $TZ
# This address is not just how the runner daemon reaches Gitea — it is
# handed to every job as GITHUB_SERVER_URL, and a job runs inside the
# runner's *nested* daemon, on a network where `gitea` does not resolve.
# So it has to be an address that works from the outside: checkout and
# `docker login` from a workflow both go out and back over HTTPS.
GITEA_INSTANCE_URL: https://gitea-${APP_DOMAIN}
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token
GITEA_RUNNER_NAME: gitea-runner
# Registration-time labels. runner-config/config.yaml repeats them, and
# that copy wins on every start, so an installed runner follows store updates.
GITEA_RUNNER_LABELS: ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04,docker:docker://docker:28.5-cli
CONFIG_FILE: /config/config.yaml
# DOCKER_TLS_CERTDIR is deliberately left at the image's default. Emptying
# it does not turn the nested daemon's TCP listener off — it turns the
# listener's TLS off, and 2375 then answers unauthenticated to anything
# that can reach this container.
volumes:
# .runner (the registration this runner is identified by) and the
# registration token the init step leaves for it.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/runner:/data
- ${DATA_ROOT:-/DATA}/AppData/$AppID/runner-config:/config:ro
# Replaces the image's own start-up wait — see the comments in the
# script. It is declared under x-compose-app.files rather than left to
# the seed tree, because it has to arrive executable.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/runner-config/act_runner-run:/etc/s6/act_runner/run:ro
# The inner daemon's image store. Without this every restart re-pulls
# every base image a workflow uses.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/runner-docker:/var/lib/docker
networks:
- gitea-internal
networks:
pcs:
name: ${APP_NET:-pcs}
external: true
# The runner has no business being reachable from other apps — it only ever
# talks to gitea and to the internet.
gitea-internal:
driver: bridge
x-casaos:
architectures:
- amd64
main: gitea
developer: Gitea
category: Developer
tags: ["developer", "git", "ci"]
description:
en_us: |
Gitea — a git forge with CI and a container registry
Gitea is a lightweight, self-contained software forge: repositories, issues, pull requests, releases, a CI engine and an OCI container registry in a single service that is happy on a small machine.
What it gives you here:
• Git hosting over HTTPS, with issues, pull requests, releases and wikis
• Actions — CI workflows in GitHub Actions syntax, so existing workflow files usually run unchanged
• A built-in container registry, so a workflow can build an image and push it next to the code it came from
• Pull mirrors — point a repository at any git URL and it is re-fetched on a schedule, and each sync starts the same workflows a push would
That last pair is what makes it useful in front of a git source that has no webhooks of its own: mirror the repository, and the mirror's own updates drive the build.
A runner ships with the app, with its own Docker daemon inside it, so workflows can build container images out of the box.
fr_fr: |
Gitea — une forge git avec CI et registre de conteneurs
Gitea est une forge logicielle légère et autonome : dépôts, tickets, pull requests, versions, moteur d'intégration continue et registre de conteneurs OCI dans un seul service, à l'aise sur une petite machine.
Ce que vous obtenez ici :
• Hébergement git en HTTPS, avec tickets, pull requests, versions et wikis
• Actions — des workflows d'intégration continue en syntaxe GitHub Actions, si bien qu'un fichier de workflow existant tourne généralement tel quel
• Un registre de conteneurs intégré : un workflow peut construire une image et la publier à côté du code dont elle provient
• Miroirs — pointez un dépôt vers n'importe quelle URL git, il est re-synchronisé périodiquement, et chaque synchronisation déclenche les mêmes workflows qu'un push
Ces deux derniers points sont ce qui rend Gitea utile devant une source git dépourvue de webhooks : mettez le dépôt en miroir, et ce sont les mises à jour du miroir qui pilotent la construction.
Un runner est fourni avec l'application, avec son propre démon Docker embarqué, afin que les workflows puissent construire des images de conteneurs dès l'installation.
zh_cn: |
Gitea — 带 CI 与容器仓库的 Git 代码托管
Gitea 是轻量、自足的软件协作平台:仓库、议题、合并请求、发布、CI 引擎与 OCI 容器仓库集于一个服务,在小型机器上也能流畅运行。
它在这里提供:
• 通过 HTTPS 托管 Git,附带议题、合并请求、发布与百科
• Actions —— 采用 GitHub Actions 语法的 CI 工作流,现有的工作流文件通常无需修改即可运行
• 内置容器仓库,工作流可以构建镜像并发布在源代码旁边
• 拉取镜像同步 —— 将仓库指向任意 Git URL,它会按计划重新拉取,每次同步都会触发与推送相同的工作流
后两项让 Gitea 特别适合放在没有 webhook 的 Git 源前面:把仓库设为镜像,镜像的更新就会驱动构建。
应用自带一个运行器,内含独立的 Docker 守护进程,因此工作流开箱即可构建容器镜像。
icon: icon.png
screenshot_link:
- screenshot-1.png
- screenshot-2.png
- screenshot-3.png
thumbnail: thumbnail.png
tagline:
en_us: A git forge with CI and a container registry, on your own domain.
fr_fr: Une forge git avec intégration continue et registre de conteneurs, sur votre domaine.
zh_cn: 运行在你自己域名下的 Git 托管,内建 CI 与容器仓库。
tips:
before_install:
en_us: |
Signing in
| | |
| --- | --- |
| Address | `https://gitea-$APP_DOMAIN` |
| User | `gitea_admin` |
| Password | your server's default app password |
Gitea uses its own account system. Self-registration is off, so
`gitea_admin` is the only account until you create more under
Site Administration.
Building from a repository that has no webhooks
New Migration → check **This repository will be a mirror**, and give it
the git URL. Gitea re-fetches on a schedule, and each sync raises the
same `push` event a real push would — so a workflow with `on: push`
runs. Commit the workflow to `.gitea/workflows/` in the mirrored
repository itself (`.github/workflows/` also works; `.forgejo/` does
not).
One thing to change by hand: a migrated repository arrives with
**Actions switched off**. Repository → Settings → Advanced Settings →
tick **Actions**, or nothing will ever run, however correct the
workflow is.
Publishing images
The registry lives at the same address as the web UI. In a workflow,
log in to `gitea-$APP_DOMAIN` and push to
`gitea-$APP_DOMAIN/<user>/<image>:<tag>`. Use an access token with the
`write:package` scope, kept as a repository secret — the token Actions
injects as `secrets.GITHUB_TOKEN` is rejected by the registry.
The CI runner
The runner holds a Docker daemon of its own and therefore runs
privileged. It is what lets a workflow build container images. If you
do not intend to run CI, stop the `gitea-runner` container — Gitea
itself is unaffected.
fr_fr: |
Connexion
| | |
| --- | --- |
| Adresse | `https://gitea-$APP_DOMAIN` |
| Utilisateur | `gitea_admin` |
| Mot de passe | le mot de passe par défaut de vos applications |
Gitea gère ses propres comptes. L'auto-inscription est désactivée :
`gitea_admin` est donc le seul compte tant que vous n'en créez pas
d'autres depuis l'administration du site.
Construire depuis un dépôt sans webhooks
Nouvelle migration → cochez **Ce dépôt sera un miroir** et indiquez
l'URL git. Gitea re-synchronise périodiquement, et chaque
synchronisation lève l'événement `push` qu'un vrai push aurait levé —
un workflow `on: push` se déclenche donc. Le fichier de workflow se
place dans `.gitea/workflows/` du dépôt mis en miroir
(`.github/workflows/` fonctionne aussi ; `.forgejo/` non).
Une chose à changer à la main : un dépôt migré arrive avec **Actions
désactivé**. Dépôt → Paramètres → Paramètres avancés → cochez
**Actions**, sinon rien ne se déclenchera, si correct que soit le
workflow.
Publier des images
Le registre est à la même adresse que l'interface web. Dans un
workflow, connectez-vous à `gitea-$APP_DOMAIN` puis publiez vers
`gitea-$APP_DOMAIN/<utilisateur>/<image>:<tag>`. Utilisez un jeton
d'accès de portée `write:package`, conservé en secret de dépôt : le
jeton fourni par Actions (`secrets.GITHUB_TOKEN`) est refusé par le
registre.
Le runner d'intégration continue
Le runner embarque son propre démon Docker et tourne donc en mode
privilégié. C'est ce qui permet à un workflow de construire des images
de conteneurs. Si vous ne comptez pas utiliser l'intégration continue,
arrêtez le conteneur `gitea-runner` — Gitea lui-même n'en dépend pas.
zh_cn: |
登录
| | |
| --- | --- |
| 地址 | `https://gitea-$APP_DOMAIN` |
| 用户 | `gitea_admin` |
| 密码 | 服务器的默认应用密码 |
Gitea 使用自己的账户体系。自助注册已关闭,因此在你于站点管理中创建更多账户之前,
`gitea_admin` 是唯一的账户。
为没有 webhook 的仓库构建
新建迁移 → 勾选**将此仓库设为镜像**并填入 git URL。Gitea 会按计划重新拉取,
每次同步都会触发与真实推送相同的 `push` 事件,因此 `on: push` 工作流会运行。
工作流文件请提交到被镜像仓库自身的 `.gitea/workflows/` 目录
(`.github/workflows/` 同样有效,`.forgejo/` 无效)。
有一处需要手动调整:迁移而来的仓库默认**关闭 Actions**。
进入 仓库 → 设置 → 高级设置 → 勾选 **Actions**,否则工作流再正确也不会运行。
发布镜像
容器仓库与网页界面位于同一地址。在工作流中登录 `gitea-$APP_DOMAIN`,
并推送到 `gitea-$APP_DOMAIN/<用户>/<镜像>:<标签>`。请使用具有
`write:package` 权限的访问令牌并存为仓库机密 —— Actions 注入的
`secrets.GITHUB_TOKEN` 会被容器仓库拒绝。
CI 运行器
运行器内含独立的 Docker 守护进程,因此以特权模式运行,这正是工作流能够构建
容器镜像的原因。若不打算使用 CI,可停止 `gitea-runner` 容器 —— 这不影响 Gitea 本身。
title:
en_us: Gitea
index: /
webui_port: 3000
store_app_id: gitea
x-compose-app:
schema_version: 2
folders:
- path: /DATA/AppData/$AppID/data
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/config
user: $PUID
group: $PGID
# Holds .runner (the runner's registration) and the token file the
# init step writes for it.
- path: /DATA/AppData/$AppID/runner
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/runner-config
user: $PUID
group: $PGID
# The inner Docker daemon's image store. dockerd runs as root inside the
# runner and takes ownership of what it creates below this point.
- path: /DATA/AppData/$AppID/runner-docker
user: $PUID
group: $PGID
mode: "0710"
files:
# Declared rather than left to the seed tree so the executable bit is
# stated, not inherited: git on a Windows-hosted checkout records every
# file 100644, and s6 refuses to spawn a `run` script it cannot execute —
# the runner then loops "unable to spawn ./run: Permission denied" and no
# workflow ever runs.
- path: /DATA/AppData/$AppID/runner-config/act_runner-run
from: runner-config/act_runner-run
ensure: always
mode: "0755"
init:
# Both steps are the gitea binary acting on its own installation, and both
# have to wait for the server: post_up fires as soon as `compose up`
# returns, which is when the container has *started*, not when Gitea has
# migrated its schema. Run them any sooner and `user create` dies on
# "no such table: user".
#
# The wait is bounded by `timeout` rather than left open: a step that
# blocks forever would hold the install open forever.
- name: create-admin
image: gitea/gitea:1.27.3-rootless
user: $PUID:$PGID
phase: post_up
volumes:
- /DATA/AppData/$AppID/data:/var/lib/gitea
- /DATA/AppData/$AppID/config:/etc/gitea
command:
- sh
- -c
- |
timeout 240 sh -c 'until gitea admin user list >/dev/null 2>&1; do sleep 3; done'
gitea admin user create --admin --username gitea_admin \
--password "$APP_DEFAULT_PASSWORD" --email "$APP_EMAIL" \
--must-change-password=false \
|| echo "admin account already present, leaving it alone"
# Re-run rather than remembered: the command is idempotent, and an account
# that was deleted by accident comes back on the next start.
when: always
- name: runner-token
image: gitea/gitea:1.27.3-rootless
user: $PUID:$PGID
phase: post_up
# Unlike the account, a runner token is not a database write — it is a
# call to the running server's internal API, so this one needs to be able
# to reach it.
network: $APP_NET
volumes:
- /DATA/AppData/$AppID/data:/var/lib/gitea
- /DATA/AppData/$AppID/config:/etc/gitea
- /DATA/AppData/$AppID/runner:/runner
command:
- sh
- -c
- |
timeout 240 sh -c 'until gitea actions generate-runner-token > /runner/token.new 2>/dev/null; do sleep 3; done'
mv /runner/token.new /runner/token
chmod 600 /runner/token
# Only while the runner has no registration of its own. Once it has one
# the token is dead weight, and re-minting it on every start would leave
# a live credential lying in the app folder for no reason.
when: absent:/DATA/AppData/$AppID/runner/.runner
webui-host: gitea-${domain}
webui-path: /
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+86
View File
@@ -0,0 +1,86 @@
# Gitea — Rationale
## What deviation / exception is being requested
1. `gitea-runner` runs with `privileged: true`.
2. Gitea is configured with `[migrations] ALLOW_LOCALNETWORKS = true`.
3. The admin account is called `gitea_admin`, not the deployment's usual `admin`.
## Why it is necessary
**1. The privileged runner.** The point of shipping a runner at all is that a
workflow can build a container image. That needs a Docker daemon, and there are
only two ways to give a CI job one:
- mount the host's `/var/run/docker.sock` into the runner, which hands every
workflow — including one that arrives from a mirrored third-party repository —
full control of the host's daemon, i.e. root on the PCS; or
- give the runner a daemon of its own inside the container (`dind`), which
requires `privileged`.
The second is the smaller exposure: the blast radius is the runner's own
container and its own image store, not the host's containers, volumes and
socket. It is also what upstream ships the `-dind` tag for.
The rootless dind variant (`0.6.1-dind-rootless`) would have avoided
`privileged` altogether, and was tried first. It does not start on this
platform: Ubuntu sets `kernel.apparmor_restrict_unprivileged_userns=1`, and
rootlesskit dies with `fork/exec /proc/self/exe: operation not permitted`.
Making it work means turning that protection off for the container
(`security_opt: apparmor=unconfined`), which weakens the host more than
`privileged` on a single container does. Revisit if the platform ever ships
that sysctl relaxed, or if upstream's runner learns to use the host's
user-namespace remapping.
**2. `ALLOW_LOCALNETWORKS`.** Gitea refuses by default to migrate or mirror
from an address on a private network. The app's most useful configuration —
mirroring a repository from another app on the same server, e.g. a Radicle
node's git endpoint at `http://radicle-api:8080/<rid>.git` — is exactly that
case. Without the flag the URL is rejected before any fetch is attempted.
**3. The admin username.** `admin` is on Gitea's reserved-name list (it
collides with the `/admin` route) and account creation fails with it.
## Security mitigations in place
- The runner is not on the shared `pcs` network. It sits on an app-internal
bridge with Gitea and nothing else, so no other app can reach it, and it
publishes no port.
- The runner's daemon is nested: its image store is a directory under the
app's own AppData, and the containers it creates are invisible to, and
cannot see, the host's daemon.
- `capacity: 1` in the runner config and a 2G memory limit bound what a
runaway build can consume.
- Self-registration is disabled (`DISABLE_REGISTRATION`) and anonymous
browsing is off (`REQUIRE_SIGNIN_VIEW`), so the forge starts closed with a
single account whose password is the deployment's default app password.
- `ALLOW_LOCALNETWORKS` is reachable only by an authenticated user of this
Gitea — with registration disabled, that is the owner. It widens what the
owner can point a mirror at; it does not expose anything to an anonymous
caller.
- The runner is severable: stopping the `gitea-runner` container leaves a
fully working forge and removes the privileged container from the box. This
is called out in `tips.before_install`.
## Alternatives considered and rejected
| Alternative | Why not |
|---|---|
| Host Docker socket in the runner | Any workflow, including one from a mirrored repo, gets root on the host. Strictly worse than a privileged container. |
| `0.6.1-dind-rootless` | Does not start under Ubuntu's unprivileged-userns restriction (see above); the workaround disables AppArmor for the container. |
| Ship no runner | Gitea Actions is the reason to choose Gitea over a plain git server here, and a runner cannot be added later without the same `privileged` decision. |
| Mirror over the public HTTPS address instead of `ALLOW_LOCALNETWORKS` | Works, but routes a same-box fetch out to the gateway and back, and breaks if the deployment has no public address. |
## Data protection
Everything that survives a restart is under `/DATA/AppData/gitea/`:
repositories and the SQLite database in `data/`, the rendered `app.ini` in
`config/`, the runner's registration in `runner/`, and the nested daemon's
image store in `runner-docker/`. Nothing is written outside that tree, and no
user directory (`/DATA/Documents`, `/DATA/Media`, …) is mounted into any
container.
The only credential the app generates is the runner registration token, which
is rewritten on every start and is worthless without network access to Gitea.
The admin password is the deployment's own `APP_DEFAULT_PASSWORD`; it is never
written to disk by this app beyond Gitea's own salted hash.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Replaces the image's own /etc/s6/act_runner/run. Two waits, both of which the
# stock script gets wrong for this deployment.
#
# 1. The nested Docker daemon. Upstream waits for it through s6's readiness
# notification. On a cold start with a warm image store dockerd takes longer
# than s6-notifyoncheck's window, the notification never arrives, s6 marks
# the service permanently failed, and act_runner sits blocked in
# `s6-svwait -U` for two to four minutes before s6 gives up and lets it past
# anyway. The runner shows "offline" in the forge for that whole time and
# picks up no jobs. Asking the daemon directly is the same wait without the
# guesswork.
#
# 2. The registration token. It is minted by an init step that runs after the
# stack is up, so on a first install it does not exist yet when this
# container starts — and the stock entrypoint reads the token file exactly
# once, before its retry loop, so a token that arrives late is never seen.
while [ ! -d /etc/s6/docker/supervise ]; do
sleep 0.1
done
while ! docker info >/dev/null 2>&1; do
sleep 1
done
while [ ! -s "${GITEA_RUNNER_REGISTRATION_TOKEN_FILE:-/data/token}" ] && [ ! -s /data/.runner ]; do
echo "waiting for the runner registration token"
sleep 2
done
exec run.sh
+40
View File
@@ -0,0 +1,40 @@
# act_runner configuration. Only the settings that differ from upstream's
# defaults are written out — `act_runner generate-config` prints the full set.
log:
level: info
runner:
# The file the runner's own registration is kept in, relative to /data.
file: .runner
# One job at a time. A PCS shares its CPU with everything else installed on
# it, and an image build is the least polite thing on the box.
capacity: 1
timeout: 1h
fetch_timeout: 5s
fetch_interval: 2s
# Overrides the labels stored at registration (.runner). These are upstream
# act_runner defaults: Gitea's own job images, which carry node, bash, git
# and the docker CLI with buildx, so marketplace actions (actions/checkout,
# docker/build-push-action, ...) run as they do on GitHub. The registration
# labels pointed at node:22-bookworm, which has no docker CLI.
labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
- "docker:docker://docker:28.5-cli"
cache:
# The cache server binds an address inside the runner container, which the
# job containers — created by the Docker daemon nested inside that same
# container — cannot route to. Left on, every `actions/cache` step fails
# noisily instead of being skipped.
enabled: false
container:
# Jobs run on the runner's own nested daemon, on its default bridge: they
# reach the internet, and they reach Gitea the way anything outside would,
# through its public address.
network: bridge
privileged: false
force_pull: false
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+658
View File
@@ -0,0 +1,658 @@
name: ipcr-forge
# IPCR Forge — the whole decentralised forge in one app:
#
# git push rad main v1.2.3 (developer)
# → radicle-node / radicle-api source of truth, peer-to-peer
# → radicle-gitea-bridge copies repos that commit .gitea/workflows/
# → gitea + gitea-runner GitHub-style Actions build the image
# → gitea registry staging: gitea-<domain>/gitea_admin/<app>:1.2.3
# ┄ (not wired yet) package webhook → IPCR import
# → ipcr-kubo / ipcr-registry / ipcr-gateway
# docker pull ipcr.localhost:4767/ipns/<name>:1.2.3 (anyone)
#
# Every service is copied verbatim from its standalone listing (apps/Radicle, apps/Gitea,
# apps/Radicle-Gitea-Bridge, apps/IPCR), so each keeps its own comments and rationale.md.
# Only the data folders move, one subfolder per part: $AppID/{radicle,gitea,bridge,ipcr}/.
#
# Replaces those four apps; it cannot sit beside them. It uses the same container names,
# domains (radicle-, radicle-api-, gitea-) and host ports (8776, 4768, 127.0.0.1:4767).
#
# The handoff from Gitea's registry to IPFS is the one missing link: the runner's jobs run in
# a nested Docker that cannot reach ipcr.localhost (it is the *host's* loopback), so the
# import has to run on IPCR's side, triggered by Gitea's "package published" webhook.
services:
# ═══ Radicle — the source of truth ═════════════════════════════════════════════
# Verbatim from apps/Radicle; only its data folder moves to $AppID/radicle/.
# The web UI: the Radicle Explorer bundle served as static files by Caddy.
# It is a pure client-side app — every request it makes goes from the visitor's
# browser straight to the API below, which is why that one needs its own domain.
radicle:
image: dirk1980/radicle-explorer-container@sha256:0e31485c38a765e83a10b1a4bf71b12d4f9fec630881d171bba736a4a56a16ce
container_name: radicle
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 80
deploy:
resources:
limits:
memory: 128M
environment:
TZ: $TZ
# Caddy writes its autosaved config and its (unused here) certificate store
# under these two paths. The image points them at /config and /data, which are
# root-owned, so as $PUID the process cannot create them and refuses to start.
XDG_CONFIG_HOME: /tmp/caddy-config
XDG_DATA_HOME: /tmp/caddy-data
volumes:
# Replaces the image's own :80 Caddyfile — see the comments in that file.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
expose:
- 8080
labels:
caddy_0: radicle-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 8080}}"
caddy_1: radicle-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 8080}}"
caddy_2: radicle-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# radicle-httpd — the node's read-only JSON API, on its own domain.
#
# This domain is deliberately NOT behind the SSO gate: the explorer above is a
# static bundle running in the visitor's browser, so it is the browser that calls
# this API, cross-origin. The daemon serves public repositories only (private ones
# are filtered out upstream) and has no write endpoints at all. See rationale.md.
radicle-api:
image: dirk1980/radicle-httpd@sha256:5e95be30e4bde55b509a65fd56f8b825cafd0f5cd752c1a13e79aad75b958895
container_name: radicle-api
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 70
deploy:
resources:
limits:
memory: 512M
command: ["--listen", "0.0.0.0:8080"]
environment:
TZ: $TZ
volumes:
# Same Radicle home as the node: the daemon reads the repository storage
# directly and talks to the node over the control socket inside it.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
healthcheck:
# curl, not wget: this image carries curl and no busybox.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/api/v1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
expose:
- 8080
labels:
caddy_0: radicle-api-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 8080}}"
caddy_1: radicle-api-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 8080}}"
caddy_2: radicle-api-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# The node itself: speaks the Radicle peer-to-peer protocol (gossip, fetch,
# announce) on TCP 8776. No HTTP, so no Caddy route and no place on the shared
# network — it only needs egress, which the app-internal bridge provides.
radicle-node:
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
container_name: radicle-node
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 50
deploy:
resources:
limits:
memory: 1G
environment:
TZ: $TZ
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
ports:
# Radicle peer-to-peer protocol. Other nodes dial in here to fetch the
# repositories this node seeds; without it the node still syncs outbound,
# but nobody can fetch from it. Hence the needs-public-ip tag.
- "8776:8776"
healthcheck:
# `rad node status` round-trips the node's control socket, which is the only
# liveness signal this container has — but it also renders the peer table, and
# that takes 3-8s on an idle node and longer while the node is syncing. Hence
# the unusually generous timeout: a 10s one fails during the first sync and
# flaps the container between healthy and unhealthy.
test: ["CMD", "rad", "node", "status"]
interval: 60s
timeout: 30s
retries: 3
start_period: 90s
networks:
- radicle-internal
# ═══ Gitea — CI and the staging registry ═══════════════════════════════════════
# Verbatim from apps/Gitea; only its data folder moves to $AppID/gitea/.
# Gitea itself — git hosting, the Actions controller, and the OCI package
# registry, all in one process. SQLite: a personal forge never has the write
# concurrency that would justify a second container for Postgres.
gitea:
image: gitea/gitea:1.27.3-rootless
container_name: gitea
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 70
deploy:
resources:
limits:
memory: 1G
environment:
TZ: $TZ
# Every URL Gitea hands out — clone URLs, the registry host, and the
# server_url the CI runner passes to jobs — is derived from ROOT_URL, so
# it has to be the address that works from outside as well as inside.
GITEA__server__ROOT_URL: https://gitea-${APP_DOMAIN}/
GITEA__server__DOMAIN: gitea-${APP_DOMAIN}
GITEA__server__HTTP_PORT: "3000"
GITEA__server__PROTOCOL: http
# The address Gitea's own CLI uses to reach the running server's internal
# API. It defaults to localhost, which is right for the server itself and
# useless from the init container that runs
# `gitea actions generate-runner-token`. It has to be set here rather than
# there: the CLI reads it out of the shared app.ini, and only this
# container's entrypoint renders environment into app.ini.
GITEA__server__LOCAL_ROOT_URL: http://gitea:3000/
# Git over SSH would need a published host port of its own. Everything
# here — clone, push, CI, registry — goes over HTTPS instead.
GITEA__server__DISABLE_SSH: "true"
GITEA__server__START_SSH_SERVER: "false"
# Skips the first-run install wizard: every value it would ask for is set
# here, and the admin account is created by an init step below.
GITEA__security__INSTALL_LOCK: "true"
GITEA__database__DB_TYPE: sqlite3
GITEA__database__PATH: /var/lib/gitea/data/gitea.db
GITEA__service__DISABLE_REGISTRATION: "true"
GITEA__service__REQUIRE_SIGNIN_VIEW: "true"
GITEA__actions__ENABLED: "true"
# Where a bare `uses: actions/checkout@v4` is resolved from.
GITEA__actions__DEFAULT_ACTIONS_URL: github
GITEA__packages__ENABLED: "true"
# Lets a pull mirror name a repository on this server's own Docker
# network — a Radicle node's git endpoint, for instance. See rationale.md.
GITEA__migrations__ALLOW_LOCALNETWORKS: "true"
# How often a pull mirror is re-fetched. The sync emits the same push
# event a real push would, so this interval is also how quickly CI
# reacts to a commit that arrives from the mirrored source.
GITEA__mirror__DEFAULT_INTERVAL: 10m
GITEA__mirror__MIN_INTERVAL: 10m
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/data:/var/lib/gitea
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/config:/etc/gitea
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/healthz"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
expose:
- 3000
labels:
caddy_0: gitea-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 3000}}"
caddy_1: gitea-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 3000}}"
caddy_2: gitea-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 3000}}"
networks:
- pcs
- gitea-internal
# The CI runner: act_runner with its own Docker daemon inside it (dind), so
# workflow jobs and the images they build never touch the host's daemon.
# See rationale.md for what `privileged` buys and what it costs.
gitea-runner:
image: gitea/act_runner:0.6.1-dind
container_name: gitea-runner
restart: unless-stopped
privileged: true
cpu_shares: 20
deploy:
resources:
limits:
memory: 2G
environment:
TZ: $TZ
# This address is not just how the runner daemon reaches Gitea — it is
# handed to every job as GITHUB_SERVER_URL, and a job runs inside the
# runner's *nested* daemon, on a network where `gitea` does not resolve.
# So it has to be an address that works from the outside: checkout and
# `docker login` from a workflow both go out and back over HTTPS.
GITEA_INSTANCE_URL: https://gitea-${APP_DOMAIN}
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token
GITEA_RUNNER_NAME: gitea-runner
# Registration-time labels. runner-config/config.yaml repeats them, and
# that copy wins on every start, so an installed runner follows store updates.
GITEA_RUNNER_LABELS: ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04,docker:docker://docker:28.5-cli
CONFIG_FILE: /config/config.yaml
# DOCKER_TLS_CERTDIR is deliberately left at the image's default. Emptying
# it does not turn the nested daemon's TCP listener off — it turns the
# listener's TLS off, and 2375 then answers unauthenticated to anything
# that can reach this container.
volumes:
# .runner (the registration this runner is identified by) and the
# registration token the init step leaves for it.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner:/data
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-config:/config:ro
# Replaces the image's own start-up wait — see the comments in the
# script. It is declared under x-compose-app.files rather than left to
# the seed tree, because it has to arrive executable.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-config/act_runner-run:/etc/s6/act_runner/run:ro
# The inner daemon's image store. Without this every restart re-pulls
# every base image a workflow uses.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-docker:/var/lib/docker
networks:
- gitea-internal
# ═══ Radicle → Gitea bridge ════════════════════════════════════════════════════
# Verbatim from apps/Radicle-Gitea-Bridge; only its data folder moves to $AppID/bridge/.
radicle-gitea-bridge:
image: ghcr.io/worph/radicle-gitea-bridge:0.1.0
container_name: radicle-gitea-bridge
user: $PUID:$PGID
# Reads two HTTP APIs and runs git. Needs no capability at all.
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["bridge"]
cpu_shares: 20
deploy:
resources:
limits:
memory: 128M
environment:
TZ: $TZ
# git wants a home; the image's is root's and this runs as $PUID.
HOME: /tmp
RAD_API: http://radicle-api:8080
GITEA_URL: http://gitea:3000
# The Gitea account that owns the copies — the one the Gitea app creates.
GITEA_OWNER: gitea_admin
GITEA_TOKEN_FILE: /secrets/gitea-token
# A repository opts in by containing this path at its head.
OPT_IN_PATH: .gitea/workflows
# Seconds between passes. Releases are not latency-sensitive.
INTERVAL: "30"
volumes:
# One bare repository per Radicle repository: a staging copy between fetch and
# push, so a push only sends what changed. Disposable: rebuilt if deleted.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/cache:/cache
# The Gitea token the init step mints. Read-only here.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro
healthcheck:
test: ["CMD-SHELL", "test -s /secrets/gitea-token && curl -fsS -o /dev/null http://radicle-api:8080/api/v1 && curl -fsS -o /dev/null http://gitea:3000/api/healthz"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
networks:
- pcs
# ═══ IPCR — the IPFS container registry ════════════════════════════════════════
# Verbatim from apps/IPCR; only its data folder moves to $AppID/ipcr/.
# ── The registry Docker talks to ────────────────────────────────────────────
# ipcrd: TLS front door on 127.0.0.1:4767. Passes /ipfs/<cid> through to the stock nerdctl
# registry below, resolves /ipns/<name>:<tag> itself, and pins every image it serves.
ipcr-gateway:
image: ghcr.io/yundera/ipcr:0.1.0
container_name: ipcr-gateway
# Root for one reason: it writes its CA into the host's Docker trust store (the narrow
# bind below), which is root-owned. See rationale.md.
user: "0:0"
# Root needs no capabilities to write root-owned files it owns; drop them all.
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["gateway"]
environment:
TZ: $TZ
TLS_DIR: /data/tls
# Docker trusts certs.d/<host:port>/ca.crt and re-reads it on every pull.
CA_EXPORT: /certs.d/ipcr.localhost:4767/ca.crt
# Pin every image this host pulls, so the node keeps — and re-shares — what it runs.
AUTO_PIN: "true"
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/tls:/data/tls
# The host's Docker trust store, so the CA above can be dropped into it: no daemon
# reload, no daemon.json edit. The parent is mounted because Compose cannot create a
# bind source whose name contains a colon (ipcr.localhost:4767). ipcrd writes exactly
# one file here. See rationale.md.
- /etc/docker/certs.d:/certs.d
ports:
# Loopback only: the client is the host's Docker daemon, which resolves ipcr.localhost
# to 127.0.0.1 (RFC 6761). Not reachable from the network, and not a web UI.
- "127.0.0.1:4767:4767"
depends_on:
- ipcr-registry
healthcheck:
test: ["CMD", "ipcrd", "health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- ipcr-internal
cpu_shares: 50
deploy:
resources:
limits:
memory: 128M
# ── Stock nerdctl IPFS registry ─────────────────────────────────────────────
# `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads.
ipcr-registry:
image: ghcr.io/yundera/ipcr:0.1.0
container_name: ipcr-registry
# Root because nerdctl run as any other uid assumes rootless mode and refuses to start
# without a rootless containerd — which `ipfs registry serve` never uses. The service has
# no volumes and no host access; capabilities are all dropped. See rationale.md.
user: "0:0"
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["registry"]
environment:
TZ: $TZ
expose:
- 5050
depends_on:
- ipcr-kubo
healthcheck:
test: ["CMD", "ipcrd", "health", "http://127.0.0.1:5050/v2/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- ipcr-internal
cpu_shares: 50
deploy:
resources:
limits:
memory: 256M
# ── Kubo, the IPFS node ─────────────────────────────────────────────────────
ipcr-kubo:
image: ipfs/kubo:v0.43.1
container_name: ipcr-kubo
user: $PUID:$PGID
restart: unless-stopped
# --enable-gc: blocks nobody pinned are collected; pinned images are never touched.
command: ["daemon", "--migrate=true", "--agent-version-suffix=docker", "--enable-gc"]
environment:
TZ: $TZ
# Applied at `ipfs init` only: DHT client mode, small connection manager. Tuned for an
# always-on small box (see the Kubo store app for measurements).
IPFS_PROFILE: lowpower
GOMEMLIMIT: 768MiB
# Read by seed/init.d/01-ipcr-node.sh on every start.
IPCR_SWARM_PORT: "4768"
IPFS_STORAGE_MAX: 20GB
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/kubo/:/data/ipfs
# Kubo's entrypoint runs every *.sh here before starting the daemon.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/init.d/:/container-init.d:ro
expose:
# RPC API: no auth, so it never leaves the app's own network.
- 5001
ports:
# libp2p swarm — the peer protocol, not HTTP, so Caddy cannot carry it. Without it the
# node still pulls, but nobody can fetch the images this host pins. 4001 is left to the
# Kubo store app so both can be installed side by side.
- "4768:4768/tcp"
- "4768:4768/udp"
healthcheck:
test: ["CMD-SHELL", "ipfs --api=/ip4/127.0.0.1/tcp/5001 id > /dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- ipcr-internal
cpu_shares: 50
deploy:
resources:
limits:
memory: 1024M
networks:
pcs:
name: ${APP_NET:-pcs}
external: true
# From apps/Radicle: egress only, for the node.
radicle-internal:
driver: bridge
# From apps/Gitea: gitea ↔ runner, nothing else.
gitea-internal:
driver: bridge
# From apps/IPCR: gateway ↔ registry ↔ kubo, nothing else.
ipcr-internal:
driver: bridge
x-casaos:
architectures:
- amd64
main: radicle
author: Yundera
developer: Yundera
category: Developer
tags: ["developer", "git", "ci", "needs-public-ip"]
icon: icon.png
title:
en_us: IPCR Forge
tagline:
en_us: A decentralised forge in one install — Radicle, Gitea Actions and IPFS images.
fr_fr: Une forge décentralisée en une installation — Radicle, Gitea Actions et images IPFS.
description:
en_us: |
IPCR Forge is the full path from code to running container, without a central service:
• **Radicle** holds your repositories on a peer-to-peer network. You push with `git push rad`.
• **Gitea Actions** builds them with GitHub-style workflows, triggered by a release tag.
• **IPCR** serves the resulting images from IPFS, so anyone can `docker pull` them by name.
A repository joins the pipeline by committing a workflow under `.gitea/workflows/`: there
is nothing to configure per project.
This app bundles the Radicle, Gitea, Radicle Gitea Bridge and IPCR apps. Install it instead
of them, not beside them.
fr_fr: |
IPCR Forge couvre tout le chemin du code au conteneur en production, sans service central :
• **Radicle** garde vos dépôts sur un réseau pair-à-pair. Vous poussez avec `git push rad`.
• **Gitea Actions** les construit avec des workflows au format GitHub, déclenchés par un tag.
• **IPCR** sert les images produites depuis IPFS : n'importe qui peut les `docker pull`.
Un dépôt rejoint la chaîne en ajoutant un workflow sous `.gitea/workflows/` : rien à
configurer par projet.
Cette application regroupe les applications Radicle, Gitea, Radicle Gitea Bridge et IPCR.
Installez-la à leur place, pas à côté.
tips:
before_install:
en_us: |
**Replaces four apps.** Uninstall Radicle, Gitea, Radicle Gitea Bridge and IPCR first:
this app uses the same addresses and ports.
| Address | What |
| --- | --- |
| `https://radicle-$APP_DOMAIN` | Radicle explorer (public) |
| `https://radicle-api-$APP_DOMAIN` | Radicle API and read-only git (public) |
| `https://gitea-$APP_DOMAIN` | Gitea: builds, logs, staging registry (`gitea_admin`) |
| `ipcr.localhost:4767` | IPCR registry, this server's Docker only |
**Ports.** TCP 8776 (Radicle peers) and 4768 TCP+UDP (IPFS peers) are published.
**Not wired yet:** images reach Gitea's registry automatically, but publishing them to
IPFS is still a manual `ipcr push` (see the IPCR README).
fr_fr: |
**Remplace quatre applications.** Désinstallez d'abord Radicle, Gitea, Radicle Gitea
Bridge et IPCR : cette application utilise les mêmes adresses et ports.
| Adresse | Quoi |
| --- | --- |
| `https://radicle-$APP_DOMAIN` | Explorateur Radicle (public) |
| `https://radicle-api-$APP_DOMAIN` | API Radicle et git en lecture seule (public) |
| `https://gitea-$APP_DOMAIN` | Gitea : builds, journaux, registre de transit (`gitea_admin`) |
| `ipcr.localhost:4767` | Registre IPCR, pour le Docker de ce serveur uniquement |
**Ports.** TCP 8776 (pairs Radicle) et 4768 TCP+UDP (pairs IPFS) sont publiés.
**Pas encore relié :** les images arrivent automatiquement dans le registre Gitea, mais
leur publication sur IPFS reste un `ipcr push` manuel (voir le README d'IPCR).
index: /
webui_port: 8080
store_app_id: ipcr-forge
x-compose-app:
schema_version: 2
folders:
# ── Radicle ──
- path: /DATA/AppData/$AppID/radicle/home
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/radicle/caddy
user: $PUID
group: $PGID
# ── Gitea ──
- path: /DATA/AppData/$AppID/gitea/data
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/gitea/config
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/gitea/runner
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/gitea/runner-config
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/gitea/runner-docker
user: $PUID
group: $PGID
mode: "0710"
# ── Bridge ──
- path: /DATA/AppData/$AppID/bridge/cache
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/bridge/secrets
user: $PUID
group: $PGID
mode: "0700"
# ── IPCR ──
- path: /DATA/AppData/$AppID/ipcr/kubo
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/ipcr/init.d
user: $PUID
group: $PGID
files:
# Each entry and its reason are in the standalone listing it comes from.
- path: /DATA/AppData/$AppID/radicle/home/config.json
from: radicle/home/config.json.tmpl
ensure: always
mode: "0644"
- path: /DATA/AppData/$AppID/radicle/caddy/Caddyfile
from: radicle/caddy/Caddyfile.tmpl
ensure: always
mode: "0644"
- path: /DATA/AppData/$AppID/gitea/runner-config/act_runner-run
from: gitea/runner-config/act_runner-run
ensure: always
mode: "0755"
- path: /DATA/AppData/$AppID/ipcr/init.d/01-ipcr-node.sh
from: ipcr/init.d/01-ipcr-node.sh
ensure: always
mode: "0755"
init:
# In order: Radicle's identity before its node starts, then Gitea's admin, then the
# runner token and the bridge's credentials, which both need that admin to exist.
- name: radicle-identity
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
user: $PUID:$PGID
volumes:
- /DATA/AppData/$AppID/radicle/home:/radicle-home
command: ["sh", "-c", "yes '' | rad auth --alias radicle-${APP_PUBLIC_IP_DASH} --stdin"]
when: absent:/DATA/AppData/$AppID/radicle/home/keys/radicle
- name: gitea-admin
image: gitea/gitea:1.27.3-rootless
user: $PUID:$PGID
phase: post_up
volumes:
- /DATA/AppData/$AppID/gitea/data:/var/lib/gitea
- /DATA/AppData/$AppID/gitea/config:/etc/gitea
command:
- sh
- -c
- |
timeout 240 sh -c 'until gitea admin user list >/dev/null 2>&1; do sleep 3; done'
gitea admin user create --admin --username gitea_admin \
--password "$APP_DEFAULT_PASSWORD" --email "$APP_EMAIL" \
--must-change-password=false \
|| echo "admin account already present, leaving it alone"
when: always
- name: gitea-runner-token
image: gitea/gitea:1.27.3-rootless
user: $PUID:$PGID
phase: post_up
network: $APP_NET
volumes:
- /DATA/AppData/$AppID/gitea/data:/var/lib/gitea
- /DATA/AppData/$AppID/gitea/config:/etc/gitea
- /DATA/AppData/$AppID/gitea/runner:/runner
command:
- sh
- -c
- |
timeout 240 sh -c 'until gitea actions generate-runner-token > /runner/token.new 2>/dev/null; do sleep 3; done'
mv /runner/token.new /runner/token
chmod 600 /runner/token
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
- name: bridge-credentials
image: ghcr.io/worph/radicle-gitea-bridge:0.1.0
user: $PUID:$PGID
phase: post_up
network: $APP_NET
volumes:
- /DATA/AppData/$AppID/bridge/secrets:/secrets
command:
- sh
- -c
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" bridge-init
when: absent:/DATA/AppData/$AppID/bridge/secrets/gitea-token
webui-host: radicle-${domain}
webui-path: /
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Replaces the image's own /etc/s6/act_runner/run. Two waits, both of which the
# stock script gets wrong for this deployment.
#
# 1. The nested Docker daemon. Upstream waits for it through s6's readiness
# notification. On a cold start with a warm image store dockerd takes longer
# than s6-notifyoncheck's window, the notification never arrives, s6 marks
# the service permanently failed, and act_runner sits blocked in
# `s6-svwait -U` for two to four minutes before s6 gives up and lets it past
# anyway. The runner shows "offline" in the forge for that whole time and
# picks up no jobs. Asking the daemon directly is the same wait without the
# guesswork.
#
# 2. The registration token. It is minted by an init step that runs after the
# stack is up, so on a first install it does not exist yet when this
# container starts — and the stock entrypoint reads the token file exactly
# once, before its retry loop, so a token that arrives late is never seen.
while [ ! -d /etc/s6/docker/supervise ]; do
sleep 0.1
done
while ! docker info >/dev/null 2>&1; do
sleep 1
done
while [ ! -s "${GITEA_RUNNER_REGISTRATION_TOKEN_FILE:-/data/token}" ] && [ ! -s /data/.runner ]; do
echo "waiting for the runner registration token"
sleep 2
done
exec run.sh
@@ -0,0 +1,40 @@
# act_runner configuration. Only the settings that differ from upstream's
# defaults are written out — `act_runner generate-config` prints the full set.
log:
level: info
runner:
# The file the runner's own registration is kept in, relative to /data.
file: .runner
# One job at a time. A PCS shares its CPU with everything else installed on
# it, and an image build is the least polite thing on the box.
capacity: 1
timeout: 1h
fetch_timeout: 5s
fetch_interval: 2s
# Overrides the labels stored at registration (.runner). These are upstream
# act_runner defaults: Gitea's own job images, which carry node, bash, git
# and the docker CLI with buildx, so marketplace actions (actions/checkout,
# docker/build-push-action, ...) run as they do on GitHub. The registration
# labels pointed at node:22-bookworm, which has no docker CLI.
labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
- "docker:docker://docker:28.5-cli"
cache:
# The cache server binds an address inside the runner container, which the
# job containers — created by the Docker daemon nested inside that same
# container — cannot route to. Left on, every `actions/cache` step fails
# noisily instead of being skipped.
enabled: false
container:
# Jobs run on the runner's own nested daemon, on its default bridge: they
# reach the internet, and they reach Gitea the way anything outside would,
# through its public address.
network: bridge
privileged: false
force_pull: false
@@ -0,0 +1,19 @@
#!/bin/sh
# Run by Kubo's entrypoint before every daemon start (after `ipfs init` on the first one),
# so a store update that changes these values reaches an installed node.
set -e
# libp2p swarm on 4768 rather than Kubo's 4001: the Kubo store app already publishes 4001 and
# two apps cannot publish the same host port. Listening on the published port itself (instead of
# mapping 4768→4001) keeps the addresses the node announces dialable.
P=${IPCR_SWARM_PORT:-4768}
ipfs config --json Addresses.Swarm "[
\"/ip4/0.0.0.0/tcp/$P\", \"/ip6/::/tcp/$P\",
\"/ip4/0.0.0.0/udp/$P/quic-v1\", \"/ip6/::/udp/$P/quic-v1\",
\"/ip4/0.0.0.0/udp/$P/quic-v1/webtransport\", \"/ip6/::/udp/$P/quic-v1/webtransport\",
\"/ip4/0.0.0.0/udp/$P/webrtc-direct\", \"/ip6/::/udp/$P/webrtc-direct\"
]"
# Soft cap on the block store. Pinned images are never collected; with --enable-gc everything
# else (blocks fetched but not pinned) is swept every Datastore.GCPeriod.
ipfs config Datastore.StorageMax "${IPFS_STORAGE_MAX:-20GB}"
@@ -0,0 +1,20 @@
# Serves the Radicle Explorer single-page app.
#
# Port 8080, not 80: the installer runs every service as an unprivileged user, and
# such a process cannot bind a port below 1024. The image's own Caddyfile
# listens on :80, so this file replaces it.
:8080 {
root * /usr/share/caddy
# The explorer bundle ships with the public Radicle seeds compiled into it, so
# "/" would open on the public network rather than on this server's node. This
# sends the entry point to the node page of the API served alongside it. Every
# other path is left alone, so the wider network stays browsable.
redir / /nodes/radicle-api-${APP_DOMAIN}
# The explorer is a client-side router: /nodes/..., /repos/... and friends must
# resolve to the app shell rather than to a 404.
try_files {path} /index.html
file_server
encode gzip
}
@@ -0,0 +1,15 @@
{
"preferredSeeds": [
"z6MksmpU5b1dS7oaqF2bHXhQi1DWy2hB7Mh9CuN7y1DN6QSz@seed.radicle.dev:8776",
"z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776",
"z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776"
],
"node": {
"alias": "radicle-${APP_PUBLIC_IP_DASH}",
"listen": ["0.0.0.0:8776"],
"externalAddresses": ["radicle-${APP_PUBLIC_IP_DASH}.sslip.io:8776"],
"seedingPolicy": {
"default": "block"
}
}
}
+334
View File
@@ -0,0 +1,334 @@
name: radicle
services:
# The web UI: the Radicle Explorer bundle served as static files by Caddy.
# It is a pure client-side app — every request it makes goes from the visitor's
# browser straight to the API below, which is why that one needs its own domain.
radicle:
image: dirk1980/radicle-explorer-container@sha256:0e31485c38a765e83a10b1a4bf71b12d4f9fec630881d171bba736a4a56a16ce
container_name: radicle
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 80
deploy:
resources:
limits:
memory: 128M
environment:
TZ: $TZ
# Caddy writes its autosaved config and its (unused here) certificate store
# under these two paths. The image points them at /config and /data, which are
# root-owned, so as $PUID the process cannot create them and refuses to start.
XDG_CONFIG_HOME: /tmp/caddy-config
XDG_DATA_HOME: /tmp/caddy-data
volumes:
# Replaces the image's own :80 Caddyfile — see the comments in that file.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
expose:
- 8080
labels:
caddy_0: radicle-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 8080}}"
caddy_1: radicle-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 8080}}"
caddy_2: radicle-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# radicle-httpd — the node's read-only JSON API, on its own domain.
#
# This domain is deliberately NOT behind the SSO gate: the explorer above is a
# static bundle running in the visitor's browser, so it is the browser that calls
# this API, cross-origin. The daemon serves public repositories only (private ones
# are filtered out upstream) and has no write endpoints at all. See rationale.md.
radicle-api:
image: dirk1980/radicle-httpd@sha256:5e95be30e4bde55b509a65fd56f8b825cafd0f5cd752c1a13e79aad75b958895
container_name: radicle-api
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 70
deploy:
resources:
limits:
memory: 512M
command: ["--listen", "0.0.0.0:8080"]
environment:
TZ: $TZ
volumes:
# Same Radicle home as the node: the daemon reads the repository storage
# directly and talks to the node over the control socket inside it.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/home/:/radicle-home/
healthcheck:
# curl, not wget: this image carries curl and no busybox.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/api/v1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
expose:
- 8080
labels:
caddy_0: radicle-api-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 8080}}"
caddy_1: radicle-api-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 8080}}"
caddy_2: radicle-api-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# The node itself: speaks the Radicle peer-to-peer protocol (gossip, fetch,
# announce) on TCP 8776. No HTTP, so no Caddy route and no place on the shared
# network — it only needs egress, which the app-internal bridge provides.
radicle-node:
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
container_name: radicle-node
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 50
deploy:
resources:
limits:
memory: 1G
environment:
TZ: $TZ
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/home/:/radicle-home/
ports:
# Radicle peer-to-peer protocol. Other nodes dial in here to fetch the
# repositories this node seeds; without it the node still syncs outbound,
# but nobody can fetch from it. Hence the needs-public-ip tag.
- "8776:8776"
healthcheck:
# `rad node status` round-trips the node's control socket, which is the only
# liveness signal this container has — but it also renders the peer table, and
# that takes 3-8s on an idle node and longer while the node is syncing. Hence
# the unusually generous timeout: a 10s one fails during the first sync and
# flaps the container between healthy and unhealthy.
test: ["CMD", "rad", "node", "status"]
interval: 60s
timeout: 30s
retries: 3
start_period: 90s
networks:
- radicle-internal
networks:
pcs:
name: ${APP_NET:-pcs}
external: true
# Egress-only network for the node. Nothing else joins it — the node and the
# API daemon share state through the Radicle home directory, not over TCP.
radicle-internal:
driver: bridge
x-casaos:
architectures:
- amd64
main: radicle
developer: Radicle
category: Developer
tags: ["developer", "personal", "needs-public-ip"]
description:
en_us: |
Radicle — your own peer-to-peer forge
Radicle is a sovereign alternative to GitHub and GitLab. Instead of a company's server holding the canonical copy of your code, every participant keeps a full replica and gossips changes directly to peers. Repositories, issues and patches are all Git objects, signed by their author and replicated across the network — there is no central service to go down, change its terms, or lock you out.
This app runs a complete Radicle node on your server, plus the official Radicle Explorer web interface to browse it:
• Your node stays online and keeps your repositories available even when your laptop is not
• Anything you push from your machine is replicated here and served to the network
• Issues, patches and code are browsable in your own web UI, on your own domain
• Anyone can clone your repositories over plain HTTPS, no account required
The web interface is for browsing. Creating repositories, opening issues and proposing patches happen with the `rad` command line on your own machine — the node here is what keeps them alive and reachable.
Repositories are only replicated once you seed them. A fresh node starts empty and stores nothing until you tell it what to follow.
fr_fr: |
Radicle — votre forge pair-à-pair
Radicle est une alternative souveraine à GitHub et GitLab. Plutôt qu'un serveur d'entreprise détenant la copie de référence de votre code, chaque participant conserve un réplica complet et propage ses modifications directement à ses pairs. Dépôts, tickets et patches sont tous des objets Git, signés par leur auteur et répliqués sur le réseau — aucun service central ne peut tomber, changer ses conditions ou vous verrouiller dehors.
Cette application fait tourner un nœud Radicle complet sur votre serveur, ainsi que l'interface web officielle Radicle Explorer pour le parcourir :
• Votre nœud reste en ligne et garde vos dépôts disponibles même quand votre portable ne l'est pas
• Tout ce que vous poussez depuis votre machine est répliqué ici et servi au réseau
• Tickets, patches et code se consultent dans votre propre interface web, sur votre domaine
• N'importe qui peut cloner vos dépôts en HTTPS, sans compte
L'interface web sert à consulter. Créer des dépôts, ouvrir des tickets et proposer des patches se fait avec la ligne de commande `rad` sur votre machine — le nœud installé ici est ce qui les maintient vivants et accessibles.
Les dépôts ne sont répliqués qu'une fois que vous les suivez. Un nœud neuf démarre vide et ne stocke rien tant que vous ne lui avez rien indiqué.
zh_cn: |
Radicle — 属于你自己的点对点代码托管
Radicle 是 GitHub 与 GitLab 的自主替代方案。代码的权威副本不再由某家公司的服务器保管,而是每个参与者都持有完整副本,并直接向对等节点传播更改。仓库、议题和补丁都是 Git 对象,由作者签名并在网络中复制——没有中心服务会宕机、修改条款或将你拒之门外。
此应用在你的服务器上运行一个完整的 Radicle 节点,并附带官方的 Radicle Explorer 网页界面:
• 节点持续在线,即使你的笔记本关机,仓库依然可用
• 你从本机推送的内容会复制到这里,并向网络提供服务
• 议题、补丁和代码都可以在你自己的域名下浏览
• 任何人都可以通过 HTTPS 克隆你的仓库,无需账户
网页界面用于浏览。创建仓库、提交议题和提出补丁请在本机使用 `rad` 命令行完成——这里的节点负责让它们保持在线、可被获取。
仓库只有在你选择跟随(seed)之后才会被复制。全新节点启动时是空的,在你指定之前不会存储任何内容。
icon: icon.png
screenshot_link:
- screenshot-1.png
- screenshot-2.png
- screenshot-3.png
tagline:
en_us: Your own peer-to-peer Git forge — a Radicle node and web explorer on your domain.
fr_fr: Votre forge Git pair-à-pair — un nœud Radicle et son explorateur web sur votre domaine.
zh_cn: 你自己的点对点 Git 托管——运行在你域名下的 Radicle 节点与网页浏览器。
thumbnail: thumbnail.png
tips:
before_install:
en_us: |
This app is public on purpose
A Radicle node is a participant in a public peer-to-peer network. Both of
its addresses are served directly to the public, bypassing the server's
single sign-on:
| Address | What it serves |
| ------- | -------------- |
| `https://radicle-$APP_DOMAIN` | The web interface |
| `https://radicle-api-$APP_DOMAIN/api/v1` | The read-only API the interface calls |
Only **public** repositories are served, the API has no write endpoints, and
a fresh node holds nothing until you seed something. See `rationale.md`.
Connecting your machine
Install the `rad` CLI from [radicle.dev](https://radicle.dev), then push a
repository and tell this node to seed it. Your node's identity (its Node ID)
is printed at `https://radicle-api-$APP_DOMAIN/api/v1` under `nid`.
Peer-to-peer port
The node publishes TCP **8776** so other peers can fetch from it. On a server
without a directly reachable public IP the app still works — your repositories
replicate outward to public seeds — but nobody fetches from you directly.
fr_fr: |
Cette application est publique, volontairement
Un nœud Radicle participe à un réseau pair-à-pair public. Ses deux adresses
sont servies directement au public, en contournant l'authentification
unique du serveur :
| Adresse | Ce qu'elle sert |
| ------- | --------------- |
| `https://radicle-$APP_DOMAIN` | L'interface web |
| `https://radicle-api-$APP_DOMAIN/api/v1` | L'API en lecture seule appelée par l'interface |
Seuls les dépôts **publics** sont servis, l'API n'a aucun point d'écriture, et
un nœud neuf ne contient rien tant que vous n'avez rien suivi. Voir
`rationale.md`.
Connecter votre machine
Installez la CLI `rad` depuis [radicle.dev](https://radicle.dev), puis poussez
un dépôt et demandez à ce nœud de le suivre. L'identité de votre nœud (Node ID)
est affichée sur `https://radicle-api-$APP_DOMAIN/api/v1`, champ `nid`.
Port pair-à-pair
Le nœud publie le port TCP **8776** pour que les autres pairs puissent récupérer
ses dépôts. Sur un serveur sans IP publique directement joignable, l'application
fonctionne quand même — vos dépôts se répliquent vers les seeds publics — mais
personne ne récupère directement chez vous.
zh_cn: |
此应用有意保持公开
Radicle 节点是公开点对点网络的参与者。它的两个地址都直接向公众开放,
绕过服务器的单点登录:
| 地址 | 提供的内容 |
| ---- | ---------- |
| `https://radicle-$APP_DOMAIN` | 网页界面 |
| `https://radicle-api-$APP_DOMAIN/api/v1` | 界面调用的只读 API |
仅提供**公开**仓库,API 没有任何写入接口,全新节点在你指定跟随之前不含任何内容。
详见 `rationale.md`。
连接你的机器
从 [radicle.dev](https://radicle.dev) 安装 `rad` 命令行,然后推送仓库并让此节点
跟随它。节点身份(Node ID)显示在 `https://radicle-api-$APP_DOMAIN/api/v1` 的
`nid` 字段。
点对点端口
节点公开 TCP **8776**,供其他对等节点抓取。若服务器没有可直接访问的公网 IP,应用
仍可使用——你的仓库会向公共 seed 节点复制——但别人无法直接从你这里抓取。
title:
en_us: Radicle
index: /
webui_port: 8080
store_app_id: radicle
x-compose-app:
schema_version: 2
folders:
- path: /DATA/AppData/$AppID/home
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/caddy
user: $PUID
group: $PGID
init:
# Creates the node's Ed25519 identity. Radicle has no first-run wizard: without
# a key pair in the Radicle home, radicle-node exits immediately. `rad auth`
# prompts for an optional passphrase, hence the empty line on stdin — a seed
# node's key secures peer connections and signs nothing permanent, so upstream's
# own guidance is to leave it unencrypted (a passphrase would have to be typed
# in at every start).
- name: init-identity
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
user: $PUID:$PGID
volumes:
- /DATA/AppData/$AppID/home:/radicle-home
command: ["sh", "-c", "yes '' | rad auth --alias radicle-${APP_PUBLIC_IP_DASH} --stdin"]
when: absent:/DATA/AppData/$AppID/home/keys/radicle
files:
# The node's config carries the deployment's own addresses, so it is re-rendered
# on every up rather than frozen at install. `rad auth` above writes a default
# config.json first; this replaces it with ours and keeps it in step if the PCS
# domain or public IP ever changes.
#
# It also carries the bootstrap seeds, and those are NOT the compiled-in defaults.
# radicle 1.10 ships `iris.radicle.network` / `rosa.radicle.network`, whose IPv4
# addresses refuse connections on 8776 (verified from two networks, 2026-09-04) —
# a node configured with them never finds a peer. The same two nodes answer under
# their previous hostnames, which is what this file uses; radicle logs a rename
# warning for them and keeps working. Revisit when upstream's new addresses accept
# peer traffic again.
- path: /DATA/AppData/$AppID/home/config.json
from: home/config.json.tmpl
ensure: always
mode: "0644"
# Carries this deployment's API domain in a redirect, so it is re-rendered too.
- path: /DATA/AppData/$AppID/caddy/Caddyfile
from: caddy/Caddyfile.tmpl
ensure: always
mode: "0644"
# The landing path is handled by the Caddy redirect above, not here: neither
# `webui-path` nor `x-casaos.index` is interpolated, so a ${...} written in
# either reaches the browser as a literal.
webui-host: radicle-${domain}
webui-path: /
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+85
View File
@@ -0,0 +1,85 @@
# Radicle — Rationale
## What deviation / exception is being requested
Two, both on the security checklist:
1. **No authentication gate.** Neither of the app's two domains (`radicle-<domain>`, the
web UI, and `radicle-api-<domain>`, the JSON API) is behind AppShield/Authelia or any
other login.
2. **A published host port.** `8776/tcp` is bound on the host so peers on the Radicle
network can dial in.
## Why it is necessary
**A Radicle node is a public participant in a public network.** Radicle is peer-to-peer
Git: repositories are replicated between nodes and served to anyone who asks. A node
nobody can reach is not a degraded Radicle node, it is not a Radicle node at all. The
same is true of the API: `radicle-httpd` is the read side of that participation.
**The web UI cannot be gated separately from the API.** Radicle Explorer is a static
bundle that runs entirely in the visitor's browser; every piece of data it shows is
fetched by *the browser* from `radicle-api-<domain>`, cross-origin. Putting the SSO gate
in front of the UI while the API stayed open would gate nothing — the data would remain
readable at the API domain — while adding a login page and a sidecar container. It would
be a padlock drawn on the door, not a lock.
**Port 8776 is the protocol.** Radicle's peer protocol is not HTTP, so Caddy cannot carry
it. Without the published port the node still pushes outward to public seeds, but no peer
can ever fetch from *this* node — the one thing the user installed it for. The app is
tagged `needs-public-ip` so that this is visible before install.
## Security mitigations in place
- **Only public repositories are served.** `radicle-httpd` filters its repository
listings and lookups on `visibility().is_public()`; a repository created with
`rad init --private` is not listed and not reachable through the API. Nothing the user
has not published is exposed.
- **The API is read-only.** As of `radicle-httpd` 0.25 the daemon has no session,
authentication or write endpoints at all — its whole surface is `GET` over
`/repos`, `/node`, `/delegates`, `/stats`. There is nothing to authenticate *to*,
and no request can change state on the server.
- **The node stores nothing by default.** The shipped configuration sets
`seedingPolicy.default = block`: a fresh node replicates nothing until the user
explicitly seeds a repository with `rad seed <rid>`. It cannot be used as an
unbounded drop box for whatever the network gossips at it.
- **The web UI is not a control surface.** It only browses. Creating repositories,
issues and patches requires the `rad` CLI and the user's own signing key, which never
leaves their machine.
- **Ordinary container hardening.** All three services run as `$PUID:$PGID`, never root;
each has a memory limit and a CPU share; only the two HTTP services join the shared
`pcs` network, and the node — the one with the published port — sits on an app-private
bridge with no route into the rest of the deployment.
- **No credentials to leak.** The node's key pair is generated on the server and is used
to secure peer connections; it signs nothing permanent. Upstream's own seed-node guide
recommends leaving it without a passphrase for exactly this reason, since a passphrase
would have to be supplied on every start.
## Alternatives considered and rejected
- **AppShield in front of the web UI only** — rejected: the data is served by the API
domain, which must stay open for the UI to work at all, so the gate would protect
nothing while implying that it did. Misleading security is worse than none.
- **AppShield in front of both** — rejected: it breaks the app. The browser's
cross-origin calls from the UI domain to the API domain carry no SSO session, and a
peer or a `git clone` cannot log into Authelia either.
- **Not publishing 8776** — rejected as the default: it turns the node into a
write-only client that no peer can fetch from. Users whose PCS has no reachable public
IP still get a working app (repositories replicate outward), which is what the
`needs-public-ip` tag communicates.
- **A permissive seeding policy** (`default: allow`) — rejected: it would make every
install replicate whatever the network offers onto the user's disk without them
asking.
## Data protection
All state lives in `/DATA/AppData/radicle/`: `home/` (the node's key pair, repository
storage, policy and COB databases) and `caddy/` (the web server config). Both survive
uninstall/reinstall, so the node keeps its identity — its Node ID — across the app
lifecycle; losing that key would mean losing the node's identity on the network, so
nothing in the app ever regenerates it once present.
Repository content in `storage/` is Git object data that the user chose to seed. It is
public by definition, signed by its authors, and verified on fetch: a peer cannot alter
what this node serves, because every ref is signed by the key of the delegate that
published it.
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+20
View File
@@ -0,0 +1,20 @@
# Serves the Radicle Explorer single-page app.
#
# Port 8080, not 80: the installer runs every service as an unprivileged user, and
# such a process cannot bind a port below 1024. The image's own Caddyfile
# listens on :80, so this file replaces it.
:8080 {
root * /usr/share/caddy
# The explorer bundle ships with the public Radicle seeds compiled into it, so
# "/" would open on the public network rather than on this server's node. This
# sends the entry point to the node page of the API served alongside it. Every
# other path is left alone, so the wider network stays browsable.
redir / /nodes/radicle-api-${APP_DOMAIN}
# The explorer is a client-side router: /nodes/..., /repos/... and friends must
# resolve to the app shell rather than to a 404.
try_files {path} /index.html
file_server
encode gzip
}
+15
View File
@@ -0,0 +1,15 @@
{
"preferredSeeds": [
"z6MksmpU5b1dS7oaqF2bHXhQi1DWy2hB7Mh9CuN7y1DN6QSz@seed.radicle.dev:8776",
"z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776",
"z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776"
],
"node": {
"alias": "radicle-${APP_PUBLIC_IP_DASH}",
"listen": ["0.0.0.0:8776"],
"externalAddresses": ["radicle-${APP_PUBLIC_IP_DASH}.sslip.io:8776"],
"seedingPolicy": {
"default": "block"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB