Files
ipcr-forge/Apps/IPCR-Forge/docker-compose.yml
T
Worph 5227a77477 IPCR Forge: fixes from the holyhorse run
- forge-runner: 1G (act_runner was OOM-killed mid-job at 256M)
- bridge: log rad's stdout on failure; page links Gitea from its own host
  (html_url is the internal address); description fallback without it
- docs/forge.md: holyhorse verification and findings 11-14
2026-10-04 18:59:21 +00:00

825 lines
35 KiB
YAML

name: ipcr-forge
# IPCR Forge — a forge whose images are served from IPFS and whose code is mirrored to Radicle:
#
# git push origin main v1.2.3 (developer, plain git)
# → gitea the source of truth: repositories, PRs, issues, users
# → forge-runner (act_runner) Gitea Actions: .gitea/workflows/ for the tag
# → forge-registry staging: localhost:5000/<owner>/<repo>:1.2.3 (internal only)
# → ipcr-gateway follows that registry imports every tag into IPFS, publishes IPNS
# → ipcr-kubo / ipcr-registry
# docker pull ipcr.localhost:4767/ipns/<forge name>/<owner>/<repo>:1.2.3 (anyone)
#
# gitea ──system webhook──▶ ipcr-forge (bridge) ──rad init / git push rad──▶ radicle-node
# every PUBLIC repository, one way: Radicle is a distribution mirror signed by this node.
#
# The tile opens Gitea. ipcr-forge-<domain> is the forge's own page, served by the bridge: how to
# publish, the published images with the `image:` line to use, and the Radicle mirrors.
#
# Gitea, Radicle and IPCR services are copied from their standalone listings (Apps/Gitea,
# Apps/Radicle, Apps/IPCR), so each keeps its own comments and rationale.md; the differences are
# marked "Forge:". Data folders move, one subfolder per part: $AppID/{gitea,radicle,bridge,ci,ipcr}/.
#
# Replaces the Gitea, Radicle and IPCR apps; it cannot sit beside them. It uses the same container
# names, domains (gitea-, radicle-, radicle-api-) and host ports (8776, 4768, 127.0.0.1:4767).
#
# The handoff from CI to IPFS is a pull, run by IPCR: jobs run in the forge's own Docker daemon,
# which cannot reach ipcr.localhost (the *host's* loopback). So a workflow pushes to the staging
# registry, which shares that daemon's network namespace and is therefore `localhost:5000` to
# every job, and ipcr-gateway polls it over ci-internal.
services:
# ═══ Bridge — the forge's page, and Gitea → Radicle ════════════════════════════
# ipcr-forge-bridge (bridge/ in the IPCR repository). Receives Gitea's system webhook on :8081
# (internal: no Caddy route) and mirrors every public repository to the Radicle node below,
# with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080.
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
ipcr-forge:
image: ghcr.io/yundera/ipcr-forge-bridge:0.1.0
container_name: ipcr-forge
user: $PUID:$PGID
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["ipcr-forge-bridge", "serve"]
cpu_shares: 30
deploy:
resources:
limits:
memory: 256M
environment:
TZ: $TZ
GITEA_URL: http://gitea:3000
GITEA_OWNER: gitea_admin
SECRETS_DIR: /secrets
STATE_DIR: /bridge
RAD_HOME: /radicle-home
IPCR_PUBLISHED: /srv/ipcr-state/published.json
RECONCILE: 10m
tmpfs:
# HOME for git and rad.
- /tmp
volumes:
# The node's home: its storage (where repositories are created and pushed), its control
# socket (announces) and its key, which signs everything it publishes. The same mount
# radicle-api has.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
# repos.json (Gitea repository → Radicle ID) and one bare mirror per repository.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge
# The Gitea token and the webhook secret the install step writes. Read-only here.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro
# published.json → /images.json. Read-only.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro
depends_on:
- gitea
- radicle-node
healthcheck:
test: ["CMD", "ipcr-forge-bridge", "health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
expose:
- 8080
- 8081
labels:
caddy_0: ipcr-forge-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 8080}}"
caddy_1: ipcr-forge-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 8080}}"
caddy_2: ipcr-forge-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 8080}}"
networks:
- pcs
# ═══ Gitea — the source of truth ═══════════════════════════════════════════════
# From Apps/Gitea; 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
# Forge: off. Images go to the staging registry that IPCR follows; a second registry would
# be a second place an image could be published from.
GITEA__packages__ENABLED: "false"
# Forge: webhooks may reach the bridge (ipcr-forge, on this server's network) and, as by
# default, any public address. Not `private`: that would let any user's webhook reach every
# container on the shared network.
GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge
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
# ═══ CI — Gitea Actions ════════════════════════════════════════════════════════
# The runner: act_runner (plain, not its -dind variant) driving the separate daemon below over a
# unix socket. Runs unprivileged; whoever controls it controls that daemon, which is the point.
# Its config (seed/gitea/runner-config/config.yaml) puts jobs on the daemon's host network, where
# the staging registry is localhost:5000.
forge-runner:
image: gitea/act_runner:0.6.1
container_name: forge-runner
user: $PUID:$PGID
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
# The stock run.sh reads the registration token once and gives up after 10 tries; the token is
# minted by an install step that runs after the stack is up. So wait for it (or for an
# existing registration) first.
entrypoint:
- /sbin/tini
- --
- sh
- -c
- |
until [ -s /data/token ] || [ -s /data/.runner ]; do echo "waiting for the runner registration token"; sleep 2; done
until [ -S /run/dind/docker.sock ]; do sleep 1; done
exec run.sh
cpu_shares: 20
deploy:
resources:
limits:
# act_runner holds a job's checkout and actions in memory while it copies them into the
# job container: 256M was OOM-killed mid-job on the first build (holyhorse, 2026-10-04).
memory: 1G
environment:
TZ: $TZ
HOME: /data
DOCKER_HOST: unix:///run/dind/docker.sock
# Handed to every job as GITHUB_SERVER_URL, and jobs run on the daemon's network where
# `gitea` does not resolve: checkout goes out and back over HTTPS.
GITEA_INSTANCE_URL: https://gitea-${APP_DOMAIN}
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token
GITEA_RUNNER_NAME: forge-runner
# Registration-time labels; runner-config/config.yaml repeats them and wins on every start.
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
CONFIG_FILE: /config/config.yaml
volumes:
# .runner (this runner's registration) and the registration token the init step leaves.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner:/data
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-config:/config:ro
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
depends_on:
- gitea
- forge-docker
networks:
# Egress: it polls Gitea through its public address, like the jobs.
- ci-internal
# The daemon jobs run in: Docker-in-Docker, so jobs and the images they build never touch the
# host's daemon. See rationale.md for what `privileged` buys and what it costs. No TCP listener:
# its only API is a unix socket in a folder shared with forge-runner, owned by $PGID.
forge-docker:
image: docker:29.1-dind
container_name: forge-docker
privileged: true
restart: unless-stopped
cpu_shares: 20
command: ["--host=unix:///run/dind/docker.sock", "--group=$PGID"]
deploy:
resources:
limits:
memory: 2G
environment:
TZ: $TZ
# No certificates: with an explicit --host the entrypoint adds no TCP listener to protect.
DOCKER_TLS_CERTDIR: ""
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
# The daemon's image store. Without it every restart re-pulls every job image.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/docker:/var/lib/docker
healthcheck:
test: ["CMD", "docker", "-H", "unix:///run/dind/docker.sock", "info"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- ci-internal
# The staging registry, in the daemon's network namespace: `localhost:5000` to the daemon and to
# every job (act runs jobs on the host network, which is this namespace), so a plain
# `docker push` or buildx with `network=host` works without TLS or credentials — Docker allows
# plain HTTP to localhost only. ipcr-gateway reaches it as forge-docker:5000. Nothing
# outside ci-internal can.
forge-registry:
image: registry:3.0.0
container_name: forge-registry
user: $PUID:$PGID
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
network_mode: service:forge-docker
cpu_shares: 20
deploy:
resources:
limits:
memory: 128M
environment:
TZ: $TZ
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
# Staging only: IPCR has its own copy once imported, so old tags may be deleted.
REGISTRY_STORAGE_DELETE_ENABLED: "true"
OTEL_TRACES_EXPORTER: none
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry:/var/lib/registry
depends_on:
- forge-docker
# ═══ Radicle — the public mirror ═══════════════════════════════════════════════
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The bridge
# creates and pushes repositories in this node's storage; the node replicates them.
# 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
# ═══ 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:1.2.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"
# Optional: follow a registry. Every tag of every repository it lists (or of
# IPCR_IMPORT_REPOS) is copied into IPFS and published as /ipns/<owner-repo>:<tag>;
# a tag is re-imported when it moves. Empty = off. IPCR_IMPORT_AUTH is user:token,
# a read-only (read:package) token is enough.
# Forge: watch the CI's staging registry, over ci-internal. Internal, so no credentials.
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000}
IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-}
IMPORT_REPOS: ${IPCR_IMPORT_REPOS:-}
# Forge: every repository this node builds is published under one IPNS key, "forge", as
# /ipns/<forge key>/<repo>:<tag>. One name for the whole forge, which a DNSLink domain or an
# ENS name can point at: ipcr.localhost:4767/ipns/example.eth/<repo>:<tag>.
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
IMPORT_INTERVAL: 60s
STATE_DIR: /data/state
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/tls:/data/tls
# What the watcher already imported (tag → manifest digest).
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/data/state
# 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
# Forge only: reaches the staging registry for the import poller above.
- ci-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:1.2.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
# CI: the runner ↔ its daemon (and staging registry) ↔ ipcr-gateway; egress for builds.
ci-internal:
driver: bridge
# From Apps/IPCR: gateway ↔ registry ↔ kubo, nothing else.
ipcr-internal:
driver: bridge
x-casaos:
architectures:
- amd64
main: gitea
author: Yundera
developer: Yundera
category: Developer
tags: ["developer", "git", "ci", "needs-public-ip"]
icon: icon.png
screenshot_link:
- screenshot-1.png
- screenshot-2.png
- screenshot-3.png
title:
en_us: IPCR Forge
tagline:
en_us: Gitea with Actions, images served from IPFS, code mirrored to Radicle.
fr_fr: Gitea avec Actions, images servies depuis IPFS, code répliqué sur Radicle.
description:
en_us: |
IPCR Forge is a git forge whose releases do not depend on it staying online:
• **Gitea** is where you work: repositories, pull requests, issues, and GitHub-style Actions.
Push with plain `git`.
• **IPCR** publishes every image your workflows build on IPFS, so any server running IPCR
can `docker pull` it by name, from whichever peers hold it.
• **Radicle** receives a copy of every public repository, signed by this server, on its
peer-to-peer network: anyone can clone it, even when this server is down.
A repository publishes images by committing a workflow under `.gitea/workflows/` that
pushes to `localhost:5000`. The install creates an example, `ipcr-hello`: create a tag on it
in Gitea and watch the whole chain run.
The forge's own page explains how to publish, lists every published image with the line to
put in a compose file, and every repository mirrored to Radicle.
This app bundles the Gitea, Radicle and IPCR apps. Install it instead of them, not beside them.
fr_fr: |
IPCR Forge est une forge git dont les versions publiées ne dépendent pas de sa disponibilité :
• **Gitea** est l'endroit où l'on travaille : dépôts, pull requests, tickets, et Actions au
format GitHub. On pousse avec `git`, tout simplement.
• **IPCR** publie sur IPFS chaque image construite par vos workflows : tout serveur équipé
d'IPCR peut la `docker pull` par son nom, depuis les pairs qui la détiennent.
• **Radicle** reçoit une copie de chaque dépôt public, signée par ce serveur, sur son réseau
pair-à-pair : n'importe qui peut la cloner, même quand ce serveur est arrêté.
Un dépôt publie des images en ajoutant un workflow sous `.gitea/workflows/` qui pousse vers
`localhost:5000`. L'installation crée un exemple, `ipcr-hello` : créez-y un tag dans Gitea et
regardez toute la chaîne s'exécuter.
La page de la forge explique comment publier, liste chaque image publiée avec la ligne à
mettre dans un fichier compose, et chaque dépôt répliqué sur Radicle.
Cette application regroupe les applications Gitea, Radicle et IPCR. Installez-la à leur
place, pas à côté.
tips:
before_install:
en_us: |
**Start here: [the forge's page](https://ipcr-forge-$APP_DOMAIN)**: how to publish, the
published images, the Radicle mirrors.
**Replaces three apps.** Uninstall Gitea, Radicle and IPCR first: this app uses the same
addresses and ports.
| Address | What |
| --- | --- |
| `https://gitea-$APP_DOMAIN` | Gitea (opens from the tile). Sign in as `gitea_admin` with your default app password |
| `https://ipcr-forge-$APP_DOMAIN` | The forge's page (public) |
| `https://radicle-$APP_DOMAIN` | Radicle explorer (public) |
| `https://radicle-api-$APP_DOMAIN` | Radicle API and read-only git (public) |
| `ipcr.localhost:4767` | IPCR registry, this server's Docker only |
**Ports.** TCP 8776 (Radicle peers) and 4768 TCP+UDP (IPFS peers) are published.
**Public repositories go to Radicle.** Every public Gitea repository is copied there, signed
by this server, within seconds of each push. That cannot be undone: making a repository
private later stops the copy, and what was published stays on the network. Private
repositories are never copied.
**Who can publish images.** Anyone who can push to a repository here: a build can push any
image name to the staging registry, and IPCR publishes what it finds there under this
forge's name. Images are public once on IPFS, even from a private repository. Read a
fork's workflow before approving its run.
fr_fr: |
**Commencez ici : [la page de la forge](https://ipcr-forge-$APP_DOMAIN)** : publier, les
images publiées, les copies Radicle.
**Remplace trois applications.** Désinstallez d'abord Gitea, Radicle et IPCR : cette
application utilise les mêmes adresses et ports.
| Adresse | Quoi |
| --- | --- |
| `https://gitea-$APP_DOMAIN` | Gitea (s'ouvre depuis la tuile). Connexion : `gitea_admin` et votre mot de passe d'applications par défaut |
| `https://ipcr-forge-$APP_DOMAIN` | La page de la forge (publique) |
| `https://radicle-$APP_DOMAIN` | Explorateur Radicle (public) |
| `https://radicle-api-$APP_DOMAIN` | API Radicle et git en lecture seule (public) |
| `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.
**Les dépôts publics partent sur Radicle.** Chaque dépôt public de Gitea y est copié, signé
par ce serveur, quelques secondes après chaque push. C'est irréversible : rendre un dépôt
privé ensuite arrête la copie, et ce qui a été publié reste sur le réseau. Les dépôts privés
ne sont jamais copiés.
**Qui peut publier des images.** Toute personne qui peut pousser sur un dépôt d'ici : un
build peut pousser n'importe quel nom d'image vers le registre de transit, et IPCR publie ce
qu'il y trouve sous le nom de cette forge. Une image est publique une fois sur IPFS, même
si elle vient d'un dépôt privé. Lisez le workflow d'un fork avant d'approuver son exécution.
index: /
webui_port: 3000
store_app_id: ipcr-forge
x-compose-app:
schema_version: 2
folders:
# ── Bridge ──
- path: /DATA/AppData/$AppID/bridge/state
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/bridge/secrets
user: $PUID
group: $PGID
mode: "0700"
# ── 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
# ── Radicle ──
- path: /DATA/AppData/$AppID/radicle/home
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/radicle/caddy
user: $PUID
group: $PGID
# ── CI daemon ──
# The daemon's socket: created by root, group $PGID (dockerd --group), mode 0660.
- path: /DATA/AppData/$AppID/ci/socket
user: $PUID
group: $PGID
mode: "0750"
- path: /DATA/AppData/$AppID/ci/docker
user: $PUID
group: $PGID
mode: "0710"
- path: /DATA/AppData/$AppID/ci/registry
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. All follow store
# updates (re-rendered on every up).
- path: /DATA/AppData/$AppID/gitea/runner-config/config.yaml
from: gitea/runner-config/config.yaml
ensure: always
mode: "0644"
- 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/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 setup, 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
# The bridge's Gitea token (public-only, read:repository), the webhook secret, the system
# webhook, and the example repository. Idempotent, so on every start: it repairs the webhook.
# The admin password is used here only, never stored.
- name: forge-setup
image: ghcr.io/yundera/ipcr-forge-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" GITEA_URL=http://gitea:3000 ipcr-forge-bridge setup
when: always
webui-host: gitea-${domain}
webui-path: /