Publish your first image
This tutorial takes you from a fresh imgsrv deployment to a published image
version with a latest alias pointing at it. The goal is to make the
publishing model click by completing the full flow once. We will skip the
exhaustive option lists and the "why" — for those, see
Publishing model.
You will:
- Start
imgsrvlocally against PostgreSQL and MinIO. - Capture the bootstrap token and create a
content-writerprincipal. - Upload a primary artifact and an attachment into CAS.
- Create an image, a draft version, and attach the artifact + attachment.
- Publish the version and wait for the publish job to succeed.
- Move a
latestalias to the new version. - Download the published artifact back.
The bytes you upload do not need to be a real image file — this tutorial uses a 10 MiB placeholder. The flow is what we are learning.
Prerequisites
Docker (or Podman with docker alias), curl, jq, and openssl. About 15
minutes.
1. Start the dependencies
PostgreSQL:
docker run -d --name imgsrv-pg \
-p 5432:5432 \
-e POSTGRES_USER=imgsrv \
-e POSTGRES_PASSWORD=imgsrv \
-e POSTGRES_DB=imgsrv \
postgres:17
MinIO with a single bucket:
docker run -d --name imgsrv-minio \
-p 9000:9000 -p 9001:9001 \
-e MINIO_ROOT_USER=imgsrv \
-e MINIO_ROOT_PASSWORD=imgsrv-secret \
minio/minio server /data --console-address ':9001'
# Wait a moment, then create the bucket.
docker run --rm --network=host \
-e AWS_ACCESS_KEY_ID=imgsrv \
-e AWS_SECRET_ACCESS_KEY=imgsrv-secret \
amazon/aws-cli \
--endpoint-url http://127.0.0.1:9000 \
s3 mb s3://imgsrv
2. Start imgsrv
In a new terminal, start imgsrv pointing at both:
docker run --rm --network=host --name imgsrv \
-e IMGSRV_POSTGRES_URL='postgres://imgsrv:imgsrv@127.0.0.1:5432/imgsrv?sslmode=disable' \
-e IMGSRV_S3_ENDPOINT='127.0.0.1:9000' \
-e IMGSRV_S3_BUCKET=imgsrv \
-e IMGSRV_S3_ACCESS_KEY_ID=imgsrv \
-e IMGSRV_S3_SECRET_ACCESS_KEY=imgsrv-secret \
-e IMGSRV_S3_PATH_STYLE=true \
-e IMGSRV_CAS_PROMOTION_ENABLED=true \
ghcr.io/meigma/imgsrv:latest
Watch the log line for the bootstrap token. It looks like:
imgsrv bootstrap token (one-time): imgsrv_at_…
Copy it. We will use it as $BOOTSTRAP_TOKEN in the rest of the tutorial. In
a third terminal:
export BOOTSTRAP_TOKEN='imgsrv_at_…' # paste the token here
export IMGSRV='http://127.0.0.1:8080'
# Sanity check.
curl -sf "$IMGSRV/healthz" -o /dev/null && echo 'imgsrv is up'
3. Create a content-writer principal
The bootstrap token grants auth-manager. Use it to create a service
principal that will publish:
PRINCIPAL_ID=$(curl -sf -X POST "$IMGSRV/v1/auth/principals" \
-H "Authorization: Bearer $BOOTSTRAP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"kind": "service", "display_name": "tutorial-publisher"}' \
| jq -r .id)
ROLE_ID=$(curl -sf "$IMGSRV/v1/auth/roles" \
-H "Authorization: Bearer $BOOTSTRAP_TOKEN" \
| jq -r '.roles[] | select(.id == "content-writer") | .id')
curl -sf -X PUT \
"$IMGSRV/v1/auth/principals/$PRINCIPAL_ID/roles/$ROLE_ID" \
-H "Authorization: Bearer $BOOTSTRAP_TOKEN"
TOKEN=$(curl -sf -X POST \
"$IMGSRV/v1/auth/principals/$PRINCIPAL_ID/api-tokens" \
-H "Authorization: Bearer $BOOTSTRAP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name": "tutorial", "expires_at": "2027-01-01T00:00:00Z"}' \
| jq -r .plaintext)
echo "content-writer token: $TOKEN"
$TOKEN is the bearer credential for the rest of the tutorial.
4. Upload the primary artifact
Generate a 10 MiB placeholder file and compute its digest:
dd if=/dev/urandom of=placeholder.qcow2 bs=1M count=10 status=none
DIGEST=$(openssl dgst -sha256 placeholder.qcow2 | awk '{print $2}')
SIZE=$(wc -c < placeholder.qcow2)
echo "digest: $DIGEST size: $SIZE"
Begin the upload, then send the file as a single part:
UPLOAD_ID=$(curl -sf -X POST "$IMGSRV/v1/uploads" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"expected_digest": "sha256:'"$DIGEST"'", "expected_size_bytes": '"$SIZE"'}' \
| jq -r .id)
# Upload part 1 and capture the assigned etag + accepted size.
PART_RESPONSE=$(curl -sf -X PUT \
"$IMGSRV/v1/uploads/$UPLOAD_ID/parts/1" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/octet-stream' \
--data-binary "@placeholder.qcow2")
PART_ETAG=$(echo "$PART_RESPONSE" | jq -r .etag)
PART_SIZE=$(echo "$PART_RESPONSE" | jq -r .size_bytes)
# Complete the upload.
curl -sf -X POST \
"$IMGSRV/v1/uploads/$UPLOAD_ID/complete" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"parts": [{"number": 1, "etag": "'"$PART_ETAG"'", "size_bytes": '"$PART_SIZE"'}]}'
Poll until the upload is ready:
until [ "$(curl -sf "$IMGSRV/v1/uploads/$UPLOAD_ID" -H "Authorization: Bearer $TOKEN" | jq -r .state)" = "ready" ]; do
sleep 1
done
echo 'primary artifact upload is ready'
5. Upload the attachment
Repeat for a small placeholder attachment:
echo 'placeholder attachment' > placeholder.txt
ATT_DIGEST=$(openssl dgst -sha256 placeholder.txt | awk '{print $2}')
ATT_SIZE=$(wc -c < placeholder.txt)
ATT_UPLOAD_ID=$(curl -sf -X POST "$IMGSRV/v1/uploads" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"expected_digest": "sha256:'"$ATT_DIGEST"'", "expected_size_bytes": '"$ATT_SIZE"'}' \
| jq -r .id)
ATT_PART=$(curl -sf -X PUT \
"$IMGSRV/v1/uploads/$ATT_UPLOAD_ID/parts/1" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/octet-stream' \
--data-binary "@placeholder.txt")
curl -sf -X POST "$IMGSRV/v1/uploads/$ATT_UPLOAD_ID/complete" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"parts": [{"number": 1, "etag": "'"$(echo "$ATT_PART" | jq -r .etag)"'", "size_bytes": '"$(echo "$ATT_PART" | jq -r .size_bytes)"'}]}'
until [ "$(curl -sf "$IMGSRV/v1/uploads/$ATT_UPLOAD_ID" -H "Authorization: Bearer $TOKEN" | jq -r .state)" = "ready" ]; do
sleep 1
done
echo 'attachment upload is ready'
6. Create the image and draft version
The image is the namespace; the version is the unit of release meaning.
curl -sf -X POST "$IMGSRV/v1/images" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name": "tutorial"}'
curl -sf -X POST "$IMGSRV/v1/images/tutorial/versions" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"version": "0.1.0"}'
7. Attach the artifact and the attachment
Attach the primary artifact by digest:
ARTIFACT_ID=$(curl -sf -X POST \
"$IMGSRV/v1/images/tutorial/versions/0.1.0/artifacts" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"operating_system": "linux",
"architecture": "amd64",
"format": "qcow2",
"primary_blob_digest": "sha256:'"$DIGEST"'",
"primary_blob_size_bytes": '"$SIZE"',
"primary_media_type": "application/x-qemu-disk"
}' \
| jq -r .id)
curl -sf -X POST \
"$IMGSRV/v1/images/tutorial/versions/0.1.0/artifacts/$ARTIFACT_ID/attachments" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "incus.tar.xz",
"media_type": "application/x-incus-metadata",
"blob_digest": "sha256:'"$ATT_DIGEST"'",
"blob_size_bytes": '"$ATT_SIZE"'
}'
8. Publish
Publishing freezes the manifest and enqueues the durable publish job:
JOB_ID=$(curl -sf -X POST \
"$IMGSRV/v1/images/tutorial/versions/0.1.0/publish" \
-H "Authorization: Bearer $TOKEN" \
| jq -r .job_id)
until STATE=$(curl -sf "$IMGSRV/v1/publish-jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN" | jq -r .state) && [ "$STATE" = "succeeded" -o "$STATE" = "failed" ]; do
sleep 1
done
echo "publish job: $STATE"
You should see publish job: succeeded. If you see failed, inspect the
response body — the failed_step field tells you where the publish stopped.
9. Move the latest alias
Aliases are mutable pointers from a name to one published version.
curl -sf -X PUT \
"$IMGSRV/v1/images/tutorial/aliases/latest" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"version": "0.1.0"}'
10. Download it back
Read endpoints are unauthenticated:
curl -sf "$IMGSRV/v1/images/tutorial/aliases/latest" | jq
curl -fL -o downloaded.qcow2 \
"$IMGSRV/v1/images/tutorial/versions/0.1.0/artifacts/$ARTIFACT_ID/download"
diff -q placeholder.qcow2 downloaded.qcow2 && echo 'bytes match'
Identical bytes, served back through the same service URL the consumer would
have used in production. The publish boundary held: the version is
published, the alias points at it, and the artifact is reachable.
What you have learned
- Uploads populate CAS for a digest, independent of any release.
- Versions start as drafts that can edit their manifest freely.
- Publishing is the immutability boundary — once
succeeded, the manifest cannot change. - Aliases let consumers track moving names like
latestwithout giving up the immutability of underlying versions.
The conceptual frame behind this flow is in Publishing model. The prescriptive how-to that goes deeper (multi-artifact versions, real recovery patterns, OIDC publishers) is Publish image versions.
Clean up
docker rm -f imgsrv imgsrv-pg imgsrv-minio
rm placeholder.qcow2 placeholder.txt downloaded.qcow2