1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
|
# distroless-mirror
Public, read-only OCI mirror of the four Google distroless images, served from
the VPS on <https://img.dieschoenewolke.de>:
| Pull this | Equals upstream |
| --- | --- |
| `img.dieschoenewolke.de/distroless/static:<tag>` | `gcr.io/distroless/static:<tag>` |
| `img.dieschoenewolke.de/distroless/base:<tag>` | `gcr.io/distroless/base:<tag>` |
| `img.dieschoenewolke.de/distroless/cc:<tag>` | `gcr.io/distroless/cc:<tag>` |
| `img.dieschoenewolke.de/distroless/python3:<tag>` | `gcr.io/distroless/python3:<tag>` |
`<tag>` is one of `latest`, `nonroot`, `debug`, `debug-nonroot` — the four
multi-arch tags upstream publishes for every repo. Digest-identical mirrors of
the upstream index; `linux/amd64` and `linux/arm64` children are mirrored, the
rare architectures (arm, ppc64le, riscv64, s390x) are not.
Why: the home Forgejo registry is VPN-only, so nothing public could pull these
images. Like the cgit mirror (`git.dieschoenewolke.de`) this one lives on the
VPS and keeps serving while the home instance is down.
```
docker pull img.dieschoenewolke.de/distroless/static:nonroot
docker pull img.dieschoenewolke.de/distroless/python3@sha256:<index digest>
incus remote add img https://img.dieschoenewolke.de --protocol oci
```
## Layout
| Path | Purpose |
| --- | --- |
| `deploy/docker-compose.yml` | VPS stack (`/root/Compose/Webserver/Registry`), zot bound to `127.0.0.1:5000` |
| `deploy/config.json` | zot config: anonymous read-only, sync from `gcr.io` |
| `deploy/nginx-edge-img.conf` | Server block for `edge-nginx` |
| `scripts/verify-mirror.sh` | Digest comparison + pull verification + marker tags |
| `.forgejo/workflows/mirror-sync.yml` | Scheduled auditor (every 6 h) |
## Why zot and not `distribution/registry:2` + CI `skopeo copy`
Both were on the table. `registry:2` would have made the Forgejo workflow the
only writer (`skopeo copy --all` per changed digest) and would have needed a
push credential plus an edge carve-out for that credential, or a second
VPN-only push port.
zot wins on three points that matter here:
1. **No push surface at all.** With no `auth` block and
`anonymousPolicy: ["read"]`, zot has no path for an authenticated write —
not from the internet, not from the LAN, not from a leaked token. With
`registry:2`, read-only can only be enforced by the edge (`limit_except GET`)
plus a separate authenticated push route; the write capability exists in the
process.
2. **Freshness does not depend on the home host.** zot pulls from gcr.io on the
VPS. A `registry:2` + CI design stops updating whenever the home Forgejo (or
its runner) is down, because the copy job runs there.
3. **`dedupe`.** The four repos share most layers; zot hardlinks identical
blobs instead of storing them per repository. `registry:2` stores blobs
globally too, but has no tag-level `dedupe` and no built-in upstream sync, so
a stale mirror is a CI bug rather than a config value.
The cost of that choice: zot is a second non-standard registry binary to keep
updated, and the tag allowlist lives in `deploy/config.json` rather than in the
workflow. The auditor workflow covers the second half by failing when a ref we
already marked disappears.
## Deploy
```sh
ssh root@vpn
mkdir -p /root/Compose/Webserver/Registry
# copy deploy/docker-compose.yml, deploy/config.json
cd /root/Compose/Webserver/Registry && docker compose up -d
```
Edge: merge `deploy/nginx-edge-img.conf` into
`/root/Compose/Webserver/NginxEdge/nginx/default.conf` **in place**
(`cat new > default.conf` — it is a single-file bind mount), then
```sh
docker exec edge-nginx nginx -t
docker exec edge-nginx nginx -s reload
```
Rollback: remove the block again (`git checkout`/`cp` the backup, write in
place, reload) and `docker compose -p registry down` in the Registry directory.
The `*.dieschoenewolke.de` wildcard certificate already covers `img`, so no
certificate change is involved.
DNS: `A 45.142.177.86` and `AAAA 2a03:4000:4a:2ba::1` for
`img.dieschoenewolke.de` (deSEC).
## Verify
```sh
curl -sI https://img.dieschoenewolke.de/v2/ # 200
docker pull img.dieschoenewolke.de/distroless/static:nonroot
docker run --rm img.dieschoenewolke.de/distroless/python3:nonroot -V
./scripts/verify-mirror.sh # digest comparison + markers
```
From the VPS itself the request hits CrowdSec AppSec and can be challenged —
test from another host.
## Notes
- The edge disables AppSec for this host (docker/skopeo user agents trip CRS
bot rules); the CrowdSec IP bouncer still applies and `limit_except GET`
rejects every write verb at the edge.
- `distribution/registry`-style `/v2/` on `git.dieschoenewolke.de` is not
usable: OCI clients derive `<host>/<repo>` from the reference, so the same
hostname would need a path-stripping vhost and `GET /v2/` would collide with
cgit. Hence the dedicated name.
|