Container Builds
    Supply Chain

    Build Secure Container Images with apko and melange on a VPS

    Set up a reproducible, SBOM-attested container image build host on a RamNode VPS using Chainguard's apko and melange with Wolfi packages.

    A production guide for standing up a reproducible, SBOM-attested container image build host using Chainguard's apko and melange.

    Overview

    apko and melange are two complementary open-source tools from Chainguard. Together they replace the Dockerfile for building minimal, distroless, supply-chain-focused images:

    • melange builds .apk packages from source using declarative YAML pipelines. These are the same package format the Alpine and Wolfi package managers consume.
    • apko assembles a set of .apk packages into an OCI image from a declarative YAML manifest. There is no RUN step, no shell, and no container runtime involved in the build.

    Because an apko build is a pure package install with no arbitrary shell execution, the images are bit-for-bit reproducible: the same config and package versions produce the same image hash on any machine. Every build also emits an SPDX SBOM, so scanners like Trivy, Grype, and Snyk can read every byte of the image rather than reporting "unknown" rows against a scratch image.

    This guide sets up a RamNode VPS as a dedicated, hardened build host. Unlike the other guides in this series, the deliverable is not a long-running service; it is a repeatable build environment plus an optional private registry to publish signed images.

    For new projects in 2026, prefer the Wolfi package repository over plain Alpine. Wolfi is glibc-based, ships only modern package versions, and is curated specifically for container use. apko can point at any apk repository, so Alpine remains available for musl or Alpine-tooling dependencies.

    Prerequisites

    • A RamNode VPS running Ubuntu 24.04 LTS. A 2 GB RAM plan handles most builds; multi-architecture builds benefit from 4 GB.
    • Root or sudo access.
    • A domain pointing at the VPS if you plan to run the optional private registry with TLS.

    1. Initial server hardening

    Create a non-root user and set the baseline firewall.

    shell
    adduser build
    usermod -aG sudo build
    rsync --archive --chown=build:build ~/.ssh /home/build

    In /etc/ssh/sshd_config set PermitRootLogin no and PasswordAuthentication no, then systemctl reload ssh.

    shell
    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow OpenSSH
    sudo ufw enable

    If you add the private registry later, you will open 443 at that point. Nothing else needs to be exposed for building.

    2. Install Docker

    melange and apko run cleanly from their official container images, which is the simplest way to get the correct apk tooling without polluting the host. Install Docker Engine:

    shell
    sudo apt update && sudo apt install -y ca-certificates curl gnupg
    sudo install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
      | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
    sudo chmod a+r /etc/apt/keyrings/docker.gpg
    
    echo "deb [arch=$(dpkg --print-architecture) \
      signed-by=/etc/apt/keyrings/docker.gpg] \
      https://download.docker.com/linux/ubuntu \
      $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
      | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    
    sudo apt update
    sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
    
    sudo usermod -aG docker build

    Log out and back in so the build user picks up the docker group.

    Define convenience aliases in ~/.bashrc for the build user:

    shell
    alias melange='docker run --privileged --rm -v "$PWD":/work cgr.dev/chainguard/melange'
    alias apko='docker run --rm -v "$PWD":/work -w /work cgr.dev/chainguard/apko'

    melange requires --privileged because it builds packages inside an isolated environment. apko does not.

    3. Build your first package with melange

    melange builds an apk from a declarative pipeline. Create a working directory and a melange.yaml. This example packages a simple Go HTTP server; adapt the pipeline to your own source.

    shell
    package:
      name: hello-server
      version: 1.0.0
      epoch: 0
      description: "A minimal hello world HTTP server"
      copyright:
        - license: Apache-2.0
    
    environment:
      contents:
        repositories:
          - https://packages.wolfi.dev/os
        keyring:
          - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub
        packages:
          - build-base
          - go
    
    pipeline:
      - runs: |
          go build -o ${{targets.destdir}}/usr/bin/hello-server .

    Generate a signing key so your packages are verifiable, then build:

    shell
    melange keygen
    # writes melange.rsa (private) and melange.rsa.pub (public)
    
    melange build melange.yaml --signing-key melange.rsa --arch host

    This creates a packages/ directory with a subdirectory per architecture, each containing the signed .apk. To build for multiple architectures, replace --arch host with --arch amd64,aarch64.

    Keep melange.rsa off the build host once you move to CI. For the manual workflow here, protect it with chmod 600 and never commit it.

    4. Build the OCI image with apko

    apko composes your package into a minimal image. Create apko.yaml referencing the local package repository built in the previous step:

    shell
    contents:
      keyring:
        - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub
        - melange.rsa.pub
      repositories:
        - https://packages.wolfi.dev/os
        - '@local /work/packages'
      packages:
        - wolfi-baselayout
        - ca-certificates-bundle
        - hello-server@local
    
    accounts:
      groups:
        - groupname: nonroot
          gid: 65532
      users:
        - username: nonroot
          uid: 65532
      run-as: 65532
    
    entrypoint:
      command: /usr/bin/hello-server
    
    archs:
      - amd64

    The @local notation tells apko to resolve packages from the mounted /work/packages directory, and hello-server@local pins the package to that local repository. Build the image:

    shell
    apko build apko.yaml hello-server:1.0.0 hello-server.tar --arch host

    This produces hello-server.tar (a loadable OCI image) plus an SPDX SBOM file per architecture. Load and run it locally to verify:

    shell
    docker load < hello-server.tar
    docker run --rm -p 8080:8080 hello-server:1.0.0-amd64

    Because the image contains only your binary and its declared runtime dependencies, there is no shell and no package manager inside it, which is the point: a smaller attack surface and a clean scanner report.

    5. Sign images with cosign

    Install cosign to sign the resulting images and verify provenance. Keyless signing via Sigstore is the recommended approach in CI; for a standalone build host you can use a local key pair.

    shell
    VERSION=$(curl -s https://api.github.com/repos/sigstore/cosign/releases/latest \
      | grep -oP '"tag_name": "\K[^"]+')
    curl -Lo cosign \
      "https://github.com/sigstore/cosign/releases/download/${VERSION}/cosign-linux-amd64"
    sudo install -m 0755 cosign /usr/local/bin/cosign
    rm cosign
    
    cosign generate-key-pair

    Once an image is pushed to a registry (next section), sign and verify it:

    shell
    cosign sign --key cosign.key registry.example.com/hello-server:1.0.0
    cosign verify --key cosign.pub registry.example.com/hello-server:1.0.0

    In a CI pipeline, Chainguard publishes reusable GitHub Actions that wire melange and apko together with Sigstore keyless OIDC signing. That mints an ephemeral signing key per run and discards the private key, so no long-lived key sits in a secret store. Move to that model once you graduate from the manual workflow.

    6. Optional: a private registry to publish images

    If you want the build host to also publish images, run a lightweight OCI registry. zot is a good distroless choice; the standard registry:2 also works. Here is the standard registry behind Caddy for automatic TLS.

    Run the registry bound to localhost:

    shell
    docker run -d --restart unless-stopped \
      --name registry \
      -p 127.0.0.1:5000:5000 \
      -v /opt/registry/data:/var/lib/registry \
      registry:2

    Install Caddy and create a Caddyfile:

    shell
    registry.example.com {
        reverse_proxy 127.0.0.1:5000
        basicauth {
            ci JDJhJDE0... # bcrypt hash from: caddy hash-password
        }
    }

    Open the firewall for HTTPS only:

    shell
    sudo ufw allow 443/tcp
    sudo ufw allow 80/tcp

    Now publish directly from apko, which writes both per-architecture images and an OCI index in one call and attaches the SBOM:

    shell
    apko publish apko.yaml registry.example.com/hello-server:1.0.0 \
      --arch amd64,aarch64

    7. Keeping builds reproducible and current

    • Pin package versions in your melange and apko configs for reproducibility, and rebuild on a schedule to pull in security patches. Chainguard rebuilds its own catalog nightly for exactly this reason.
    • Store your melange.yaml, apko.yaml, and public keys in version control. Treat private signing keys as secrets kept outside the repo.
    • Verify the apko and melange images you pull. Chainguard signs them keylessly; you can confirm provenance with cosign verify against the GitHub Actions release identity.

    RamNode platform notes

    • This is a build host, not a network service, so its exposed surface is minimal. Keep only SSH open unless you run the optional registry.
    • RamNode's prohibition on mail services is not relevant to apko or melange, since neither sends email. If you add build notifications, route them through an external API-based service rather than an on-box SMTP relay.

    Troubleshooting

    • melange fails with a permissions or mount error: confirm the --privileged flag is present and that the current directory is mounted to /work.
    • apko cannot find your local package: confirm the @local /work/packages repository line is present and that hello-server@local uses the @local suffix. The packages/ directory must be in the mounted working directory.
    • Signature verification fails in apko: confirm melange.rsa.pub is listed under contents.keyring in apko.yaml.
    • SBOM warnings during build: warnings about SBOM upload types are generally safe to ignore for local builds.