Kai Ole Hartwig
11 min read
By

Code as an OCI artifact: shipping TYPO3 without baking the app code into the image

The classic container path bakes the application code into the image via COPY — turning every code release into a full image build. For a TYPO3 installation whose code changes often but whose runtime rarely does, that's the wrong coupling. This post shows how I put the code next to the images as its own OCI artifact via ORAS instead, what that delivers in day-to-day deployments — and where the honest limits of the pattern are.

TL;DR

Instead of baking the app code into an image, I build it as its own OCI artifact: CI packs vendor/, public/, packages/, and config/ into a tarball and pushes it via oras push next to the images into the same registry — same auth, same tag scheme. On deploy, a one-shot oras-init container pulls the artifact into a shared volume; the services run on an empty golden image. A code release is now a tarball push in seconds instead of an image build, runtime and code roll independently, and Renovate bumps the sources tag like any image tag. The price: one init step, because containerd doesn't (yet) mount OCI artifacts natively as volumes.

The problem: code and runtime are glued together

The classic container path: you take a PHP base image, copy the built application code into it, build a "fat" application image from that, and push it per release. It works — but it has two unpleasant properties. First: every code release is a full image build. Even if only a template changed, a new multi-layer image gets built, signed, and pushed. Second: runtime and code are coupled. A PHP security update forces a rebuild of all app images, a code fix drags the runtime along.

For a TYPO3 installation whose code changes often but whose runtime (FrankenPHP, Caddy, CrowdSec) rarely does, that's the wrong coupling. My answer: decouple the code from the runtime image and ship it as its own OCI artifact.

What is an OCI artifact anyway?

A container registry can hold more than just images. Since OCI spec 1.1, an "artifact" is simply arbitrary content with a manifest and an artifactType — a tarball, a Helm chart, an SBOM, a signature blob. It lives in the same registry, under the same tag and digest scheme, with the same auth as an image. It just isn't a runnable image, but payload-agnostic bytes.

The tool for this is ORAS (OCI Registry As Storage). oras push and oras pull behave like docker push and docker pull, but accept any file as a layer.

In my case the payload is the built app/ directory: vendor/ (Composer), public/, packages/ (sitepackages), config/, plus composer.lock, preload.php, and patches.lock.json.

The build: oras push in CI

The build runs on every Git tag. An upstream job (build:site:backend) produces the complete app/ directory as a job artifact; the ORAS job packs and pushes it:

 

oras:push:sources:
  stage: sources
  needs:
    - job: build:site:backend
      artifacts: true
  image:
    name: ghcr.io/oras-project/oras:v1.2.3
    entrypoint: [""]          # (1)
  rules:
    - if: $CI_COMMIT_TAG
  before_script:
    - oras login -u gitlab-ci-token -p "$CI_JOB_TOKEN" "$CI_REGISTRY"   # (2)
  script:
    - apk add --no-cache tar
    # Only the runtime-relevant paths — no docker/, .git/, .gitlab/
    - |
      tar -cf /tmp/app.tar \
        composer.json composer.lock preload.php patches.lock.json \
        vendor public packages config
    - |
      oras push \
        --artifact-type "application/vnd.example.typo3.sources" \
        --disable-path-validation \                                    # (3)
        "$REGISTRY_BASE/sources:$CI_COMMIT_TAG" \
        /tmp/app.tar:application/vnd.example.typo3.sources.tar         # (4)

 

Four sharp edges you need to know.

(1) Entrypoint override

The oras image has oras as its entrypoint. GitLab CI wraps script commands in sh -c "…" — without entrypoint: [""] that becomes oras sh -c "…" and the job dies with unknown command "sh".

(2) Auth with the job token

No extra secret: gitlab-ci-token plus $CI_JOB_TOKEN is enough to push into the project's own registry. Same pattern as the image push.

(3) --disable-path-validation

ORAS blocks absolute paths (/tmp/app.tar) by default as path traversal protection. Since the path is just a temporary CI directory and the tar content is relative, the risk here is zero — but the flag has to be set.

(4) Custom media type

artifactType and the layer media type (application/vnd.example.typo3.sources.tar) are self-defined vnd.* types. That way every consumer knows immediately: this is a TYPO3 sources tar, not an image.

A nice detail: promotion works with the same tool. Re-tagging an image from sha-… to the release tag is just oras cp — ORAS copies images and artifacts alike:

 

oras cp "$REGISTRY_BASE/scheduler:sha-1a2b3c4d" "$REGISTRY_BASE/scheduler:$CI_COMMIT_TAG"

The deploy: oras pull in an init container

On the prod side, a one-shot oras-init service (Docker Compose) pulls the artifact, unpacks it, and exits. The actual services wait via depends_on: service_completed_successfully:

 

services:
  oras-init:
    image: ghcr.io/oras-project/oras:v1.2.3
    restart: "no"
    user: "1000:1000"
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        set -eu
        # Clear stale content from the last deploy (the bind mount itself stays)
        find /shared -mindepth 1 -delete
        oras pull "registry.example/…/sources:${SOURCES_TAG}" -o /shared
        tar -xf /shared/app.tar -C /shared && rm /shared/app.tar
    volumes:
      - /var/www/shared/production/sources:/shared
      - /root/.docker/config.json:/.docker/config.json:ro   # auth
    environment:
      DOCKER_CONFIG: /.docker

  httpd:
    image: registry.example/images/php-runtime:1.0.3          # golden, EMPTY
    volumes:
      - /var/www/shared/production/sources:/app               # code from the artifact
    depends_on:
      oras-init:
        condition: service_completed_successfully

 

The architecture in one picture:

 

   BUILD (on git tag)                 REGISTRY                 DEPLOY (on tag bump)
 ┌────────────────────┐        ┌───────────────────┐      ┌───────────────────────┐
 │ build:site:backend │        │  sources:v1.2.3   │      │  oras-init (once)     │
 │        │           │  push  │  (vnd.…typo3.tar) │ pull │   find -delete        │
 │        ▼           │ ─────► │                   │ ───► │   oras pull -o /shared│
 │  tar app.tar       │  oras  │  scheduler:v1.2.3 │ oras │   tar -xf app.tar     │
 │  (vendor+public+…) │        │  (golden runtime) │      │        │              │
 └────────────────────┘        └───────────────────┘      │        ▼              │
                                                           │  /shared ──► /app     │
   Runtime images (php-runtime, caddy, crowdsec)           │  httpd│scheduler│queue│
   are built SEPARATELY & rarely ──────────────────────►│  (golden, no code)    │
                                                           └───────────────────────┘

 

The core: httpd, scheduler, and queue run on the golden php-runtime image without embedded code. The code arrives at runtime from the shared volume that oras-init filled. A code release is therefore a tarball push, not an image build. And because the SOURCES_TAG is a gitlab-tags datasource, Renovate bumps it in the deploy repo automatically — the GitOps deploy falls out like with any image tag.

"Why don't you just mount the artifact directly?"

The legitimate question — and the most honest part. Since 1.31/1.33, Kubernetes has an image volume source (KEP-4639) that lets a pod mount an OCI image as a read-only volume. The obvious idea: use that for the sources artifact and save the init container.

It fails on two counts. First: image volume wants an image, not an artifact. An artifact with its own artifactType is not a runnable image — the kubelet won't mount it. Second: native OCI artifact volumes only exist under CRI-O, not under containerd. And k3s runs containerd. For this stack there is simply no runtime path that mounts an OCI artifact directly as a volume.

The conclusion of that research: as long as containerd can't do artifact volumes, oras pull in an init step remains the only viable path — and a 10-line sidecar is a very small price.

Advantages

Decoupling code from runtime. Golden images (PHP, Caddy, CrowdSec) are built rarely and independently; a PHP security update rolls without a code rebuild, a code fix without a runtime rebuild.

No fat image build per release. A release is a tar plus oras push — seconds instead of a multi-stage BuildKit run.

One registry, one auth, one tool. The artifact sits next to the images, same oras login auth with the job token, oras cp for promotion. No separate artifact store — no Nexus, no extra S3 bucket.

Immutable, versioned, digest-pinned.sources:v1.2.3 is immutable; the registry ships retention, GC, and replication for free.

Architecture-agnostic. A tarball has no CPU architecture. The same artifact runs on amd64 and arm64 runtimes alike — no multi-arch build needed, a real pain point I know from images.

GitOps-ready. Renovate bumps the sources tag like any image tag — an automatic, traceable deploy.

Signable. cosign signs OCI artifacts exactly like images — supply chain provenance stays possible.

Honest footnotes

Not natively mountable (containerd). The init container is mandatory, including race handling (service_completed_successfully) and the shared bind mount lifecycle. One extra moving part.

Stale content is manual work. The shared directory has to be cleared aggressively before unpacking (find /shared -mindepth 1 -delete), otherwise old files survive the deploy. An image pull would handle that for free.

Symlink fragility. The tar must preserve relative symlinks (packages/acme → ../vendor/acme); a -h breaks them. I had to prove first that they resolve after extraction.

No layer dedup between releases. Every sources:tag is a full blob; two releases sharing 90% of vendor/ don't dedup like image layers. Registry storage grows per release — manageable via retention and GC.

Auth plumbing on deploy. ORAS pulls with the host's ~/.docker/config.json (one docker login before compose up) — the kubelet doesn't bring its registry creds here, you provide them yourself.

Small paper cuts. Entrypoint override, --disable-path-validation — nothing dramatic, but you trip over each of them once.

A less-trodden path. ORAS is solid, but OCI artifacts are a niche; that's exactly why containerd can't mount them (yet). Fewer people on a team know the pattern.

Frequently asked questions

How does the deploy get the registry credentials?+

ORAS reads ~/.docker/config.json — on the host, a single docker login before compose up is enough; the file is mounted read-only into the oras-init container. In CI, the job token handles it (gitlab-ci-token + $CI_JOB_TOKEN), no extra secret needed.

Doesn't the registry grow with every release?+

Yes — every sources:tag is a full blob without layer dedup. In practice that's manageable with the registry's retention policy and garbage collection; in return, the separate artifact store disappears entirely.

Why not use the Kubernetes image volume (KEP-4639)?+

Because it expects an image, not an artifact — the kubelet won't mount a blob with its own artifactType. Native OCI artifact volumes only exist under CRI-O so far, and k3s runs containerd. Until that changes, the oras-init step remains the pragmatic bridge.

Conclusion: when is this pattern worth it?

If your runtime is stable and golden, your code changes often, and you already use your registry as the central, signable, GitOps-driven source of truth — then a small, tagged sources artifact beats both the fat per-release image build and a separate artifact store.

The one real wish that remains is the native mount. Until containerd can do OCI artifact volumes, the oras-init sidecar is the pragmatic bridge — and it costs ten lines of YAML.

Stack context: TYPO3 14, FrankenPHP worker mode, Docker Compose on k3s/Graviton, GitLab Container Registry (S3-backed), Renovate-driven GitOps, ORAS v1.2.3.

Before the next one-line fix costs you another image build — let's talk about your deployment architecture.

Deployments that match your rate of change

If every template fix kicks off a full image build on your end — or runtime updates hang off your code releases — I'll work through with you which unit actually changes in your stack and how it should be shipped. The result: golden images that rarely roll, code releases in seconds, and a supply chain that stays signable.

Request a consultation

Freelance · DevSecOps consulting, training & software development

About the author

[Translate to English:] Foto von Kai Ole Hartwig.

Kai Ole Hartwig

Freelance DevSecOps consultant · OnlyOle Consulting

Programming since 2002 – self-taught, set up my own business with KO-Web in 2012. Over 100 projects, with a focus on security, performance, automation and quality. Today freelance: DevSecOps consulting, training and software development.