A general-purpose OCI Distribution registry that hosts every flavor of cocoonstack artifact in one place:
- Container images —
oras/crane/dockerpush and pull as usual. - OCI cloud images — disk-only artifacts (qcow2 / raw, including split parts)
with
artifactType: application/vnd.cocoonstack.os-image.v1+json. The windows builder publishes these toghcr.io/cocoonstack/windows/win11:25h2and the same shape works in epoch. - OCI VM snapshots — cocoon VM state captured by
cocoon vm saveand uploaded byepoch pushas an OCI artifact withartifactType: application/vnd.cocoonstack.snapshot.v1+json.
Epoch is vendor-agnostic at the storage layer — the registry package
only knows about blobs, manifests, and a global catalog. Cocoonstack-specific
concepts (snapshot, cloudimg) live in the snapshot/ and cloudimg/
sub-packages on top.
- Content-addressed storage — blobs deduplicated by SHA-256 digest
- OCI 1.1 Distribution API —
/v2/push/pull works withoras,crane,docker,containerd,buildah, and any OCI-compliant client - OCI artifact classification — top-level
artifactType(cocoonstack cloud-image / snapshot) plus aconfig.mediaTypefallback for plain container images - No filesystem coupling —
epoch pushandepoch pullpipe throughcocoon snapshot export/cocoon snapshot import/cocoon image import, so epoch never reads/var/lib/cocoondirectly - MySQL metadata index — queryable catalog for the web UI and control API
- SSO login — optional Google OAuth or generic OIDC for the web UI
- Token management — create and revoke bearer tokens from the dashboard
oras / crane / docker
|
▼
┌──────────────────────────┐
│ Epoch HTTP server │
┌────────────│ /v2/ /api/ /dl/{name} │────────────┐
│ └──────────────────────────┘ │
▼ ▼ ▼
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ snapshot pkg │ │ registry pkg │ │ cloudimg pkg │
│ Push / Pull │◄──────►│ blob/manifest │◄──────►│ Stream / Pull │
│ (cocoon pipe) │ │ + catalog.json │ │ (cocoon pipe) │
└───────────────┘ └─────────────────┘ └─────────────────┘
│
▼
S3 / GCS bucket
Object layout in the bucket (under prefix epoch/):
catalog.json — global repository index
manifests/<repo>/<tag>.json — manifest by tag
manifests/<repo>/_digests/<dgst>.json — manifest by content digest
blobs/sha256/<dgst> — content-addressable blob
All three artifact kinds are standard OCI 1.1 image manifests. Epoch classifies them by looking at:
| Field | Value | Kind |
|---|---|---|
artifactType |
application/vnd.cocoonstack.os-image.v1+json |
cloud image |
artifactType |
application/vnd.cocoonstack.snapshot.v1+json |
snapshot |
config.mediaType |
application/vnd.oci.image.config.v1+json |
container image |
config.mediaType |
application/vnd.docker.container.image.v1+json |
container image |
mediaType (top) |
OCI image index / Docker manifest list | container image (multi-arch) |
A snapshot manifest looks like:
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.cocoonstack.snapshot.v1+json",
"config": {
"mediaType": "application/vnd.cocoonstack.snapshot.config.v1+json",
"digest": "sha256:...",
"size": 123
},
"layers": [
{ "mediaType": "application/vnd.cocoonstack.vm.config+json", "digest": "sha256:...", "annotations": {"org.opencontainers.image.title": "config.json"} },
{ "mediaType": "application/vnd.cocoonstack.vm.state+json", "digest": "sha256:...", "annotations": {"org.opencontainers.image.title": "state.json"} },
{ "mediaType": "application/vnd.cocoonstack.vm.memory", "digest": "sha256:...", "annotations": {"org.opencontainers.image.title": "memory-ranges"} },
{ "mediaType": "application/vnd.cocoonstack.disk.qcow2", "digest": "sha256:...", "annotations": {"org.opencontainers.image.title": "overlay.qcow2"} }
],
"annotations": {
"cocoonstack.snapshot.id": "sid-...",
"cocoonstack.snapshot.baseimage": "ghcr.io/cocoonstack/cocoon/ubuntu:24.04",
"org.opencontainers.image.created": "2026-04-09T..."
}
}A cloud-image manifest follows the same shape but uses
artifactType: application/vnd.cocoonstack.os-image.v1+json and only carries
disk layers (vnd.cocoonstack.disk.qcow2[.part] / vnd.cocoonstack.disk.raw[.part]).
Grab a release tarball from GitHub Releases.
Set VERSION to the release you want (the archive filename embeds it):
VERSION=0.1.6 # pick the latest release tag from the Releases page
# Linux (amd64)
curl -fSL https://github.com/cocoonstack/epoch/releases/download/v${VERSION}/epoch_${VERSION}_Linux_x86_64.tar.gz \
| tar -xzf - epoch
sudo install -m 0755 epoch /usr/local/bin/epoch && rm epoch
# Linux (arm64)
curl -fSL https://github.com/cocoonstack/epoch/releases/download/v${VERSION}/epoch_${VERSION}_Linux_arm64.tar.gz \
| tar -xzf - epoch
sudo install -m 0755 epoch /usr/local/bin/epoch && rm epoch
# macOS (Apple Silicon)
curl -fSL https://github.com/cocoonstack/epoch/releases/download/v${VERSION}/epoch_${VERSION}_Darwin_arm64.tar.gz \
| tar -xzf - epoch
sudo install -m 0755 epoch /usr/local/bin/epoch && rm epoch
# macOS (Intel)
curl -fSL https://github.com/cocoonstack/epoch/releases/download/v${VERSION}/epoch_${VERSION}_Darwin_x86_64.tar.gz \
| tar -xzf - epoch
sudo install -m 0755 epoch /usr/local/bin/epoch && rm epochgit clone https://github.com/cocoonstack/epoch.git
cd epoch
make build # produces ./epoch| Variable | Description |
|---|---|
EPOCH_LOG_LEVEL |
debug / info / warn / error (default info) |
EPOCH_PUBLIC_URL |
Absolute base URL clients reach the server at (e.g. https://epoch.example.com). Used to anchor the OCI WWW-Authenticate realm and /v2/token. Optional — when unset epoch reconstructs from the inbound Host + X-Forwarded-Proto, which works behind nginx but not behind proxies that strip those headers. |
| Variable | Description |
|---|---|
EPOCH_S3_ENDPOINT |
S3 endpoint (with or without scheme) |
EPOCH_S3_ACCESS_KEY |
Access key |
EPOCH_S3_SECRET_KEY |
Secret key |
EPOCH_S3_BUCKET |
Bucket name |
EPOCH_S3_REGION |
Region (optional) |
EPOCH_S3_SECURE |
true or false; inferred from scheme if omitted |
EPOCH_S3_PREFIX |
Key prefix (default epoch/) |
EPOCH_S3_ENV_FILE |
Env file path (default ~/.config/epoch/s3.env) |
Registry clients (/v2/):
- Bearer token from
EPOCH_REGISTRY_TOKENor tokens created via the UI - Tokens are validated by SHA-256 hash against MySQL
- When neither is set,
/v2/writes are open to anonymous clients
Web UI and control API:
- Disabled by default (open access)
- Set
SSO_PROVIDER=googleorSSO_PROVIDER=oidcto enable session-based login - See epoch-server.yaml for the full list of SSO variables
epoch push / epoch pull / epoch get / epoch ls / epoch inspect /
epoch tag / epoch rm talk to a remote epoch server over HTTP and read
the following env vars:
| Variable | Description |
|---|---|
EPOCH_SERVER |
Base URL of the epoch HTTP server (default http://127.0.0.1:8080) |
EPOCH_REGISTRY_TOKEN |
Bearer token for write operations; same value as the server-side env |
EPOCH_COCOON_BINARY |
Override the cocoon binary path used by epoch push / epoch pull (default looks up cocoon on $PATH) |
In-progress chunked OCI uploads are spooled to disk so multi-GiB layers do
not pin RAM. The directory MUST be backed by real disk — tmpfs (the
default /tmp on most systemd hosts) defeats the spool and will OOM the
host on big pushes.
| Variable | Description |
|---|---|
EPOCH_UPLOAD_DIR |
Spool directory (default /var/cache/epoch/uploads; falls back to os.TempDir() with a loud warning if neither is creatable) |
The bundled epoch-server.service already sets CacheDirectory=epoch and
exports EPOCH_UPLOAD_DIR=/var/cache/epoch/uploads, so systemd deploys are
configured out of the box.
| Path | Purpose |
|---|---|
docker-compose.yaml |
Local MySQL and MinIO |
epoch-server.yaml |
Kubernetes Deployment template |
Dockerfile |
Container image build |
epoch-server.service |
systemd unit file |
epoch-nginx.conf |
nginx vhost — required tuning for streaming multi-GiB OCI pushes |
Start local dependencies:
export MYSQL_ROOT_PASSWORD=changeme
export MYSQL_PASSWORD=changeme
export MINIO_ROOT_USER=minioadmin
export MINIO_ROOT_PASSWORD=changeme
docker compose up -dBuild and run:
make build
export EPOCH_S3_ENDPOINT=http://127.0.0.1:9000
export EPOCH_S3_ACCESS_KEY=minioadmin
export EPOCH_S3_SECRET_KEY=changeme
export EPOCH_S3_BUCKET=epoch
export EPOCH_S3_SECURE=false
./epoch serve --addr :8080 --dsn 'epoch:epoch@tcp(127.0.0.1:3306)/epoch?parseTime=true'epoch serve # start HTTP server
epoch push NAME[:TAG] # push a local cocoon snapshot
epoch pull NAME[:TAG] # pull a snapshot or cloud image into cocoon
epoch get NAME[:TAG] # stream a cloud image's raw bytes to stdout
epoch ls [NAME] # list repositories or tags
epoch inspect NAME[:TAG] # show OCI manifest + classified kind
epoch tag SRC:OLD SRC:NEW # re-tag a manifest in place
epoch rm NAME:TAG # remove a tagStream a cocoon snapshot into the registry as an OCI artifact:
epoch push myvm # push myvm:latest
epoch push myvm -t v1 # specific tag
epoch push myvm -t v1 \
--base-image ghcr.io/cocoonstack/cocoon/ubuntu:24.04The --base-image flag is optional; when set it lands as the
cocoonstack.snapshot.baseimage annotation in the manifest. epoch never reads
/var/lib/cocoon directly — it shells out to cocoon snapshot export -o -
and uploads each tar entry as an OCI blob.
Fetch an artifact, classify it, and pipe it into cocoon:
| Kind | What happens |
|---|---|
snapshot (vnd.cocoonstack.snapshot.v1+json) |
Reassemble tar → cocoon snapshot import --name <name> |
cloud-image (vnd.cocoonstack.os-image.v1+json) |
Concatenate disk layers in title order → cocoon image import <name> |
container-image |
Rejected — pull with oras / crane / docker and let cocoon's runtime use its built-in cocoon image pull ghcr.io/... path |
epoch pull myvm:v1 # snapshot, imports as "myvm"
epoch pull myvm:v1 --name myvm-restored # snapshot with overridden local name
epoch pull windows/win11:25h2 # cloud image, imports as "win11"
epoch pull windows/win11:25h2 --name win11-testThe cocoon binary must be on $PATH; override with $EPOCH_COCOON_BINARY.
Stream the assembled disk bytes to stdout for piping. Snapshots cannot use
epoch get because they are not single contiguous artifacts — use
epoch pull instead.
epoch get windows/win11:25h2 | cocoon image import win11
ssh registry-host epoch get cocoon/ubuntu:24.04 | cocoon image import ubuntuProgress goes to stderr so the data pipe stays clean.
# 1. Authenticate against epoch
echo $EPOCH_REGISTRY_TOKEN | crane auth login -u _token --password-stdin epoch.example
# 2. Mirror the upstream artifact (split-qcow2 windows image)
crane copy ghcr.io/cocoonstack/windows/win11:25h2 \
epoch.example/windows/win11:25h2
# 3. Pull from epoch into cocoon on a node
epoch pull windows/win11:25h2
# Or, anonymously, via the public /dl/{name} short URL:
curl -fsSL https://epoch.example/dl/win11 | cocoon image import win11# On a cocoon node — cocoon CLI must be on $PATH
cocoon vm save myvm # creates snapshot in /var/lib/cocoon
epoch push myvm -t v1 \
--base-image ghcr.io/cocoonstack/cocoon/ubuntu:24.04epoch pull myvm:v1 --name myvm-restored
cocoon vm clone --from myvm-restored --to myvm-cloneepoch pull rejects container images (cocoon doesn't consume them). Use OCI
clients to mirror them through epoch:
crane copy ghcr.io/library/alpine:3.20 epoch.example/library/alpine:3.20
docker pull epoch.example/library/alpine:3.20make build # build binary
make test # race-detected tests with coverage
make lint # golangci-lint for linux and darwin
make fmt # gofumpt and goimports
make deps # tidy modules
make all # full pipeline
make help # show all targets| Project | Role |
|---|---|
| cocoon-common | Shared metadata, Kubernetes, and logging helpers |
| cocoon-operator | CocoonSet and Hibernation CRDs |
| cocoon-webhook | Admission webhook for sticky scheduling |
| vk-cocoon | Virtual kubelet provider managing VM lifecycle |