Recently, I’ve been hacking away in my homelab , and when it came time to deploy my blog, one thing became essential: having a reliable pipeline for building, scanning, and publishing container images. Whether you’re developing custom applications from scratch or creating hardened versions of existing images to strengthen their security posture, you need a robust build process you can trust.
While there are plenty of GitHub Actions in the marketplace that can handle this, there’s something uniquely powerful about owning a custom pipeline where you control every piece without drowning in YAML hell abstraction. Even better, what if you could define your entire pipeline in code and test it locally? Enter Dagger . With Dagger, your local pipeline runs are identical to what executes in CI, giving you fast feedback loops to iterate on the CI without waiting for runners.
In this guide, I’ll walk through setting up a complete pipeline that builds a container image, scans it for security vulnerabilities, and publishes it to a registry of your choice, all using Go!

The Stage
I wanted to have a container image for my blog that will be deployed on my tailnet via Argo CD. The app here is a simple static Hugo blog running on an nginx base.
To create a container image for our Kubernetes deployment, we need to have a pipeline. Normally, this pipeline can be as many steps as you deemed necessary. However, usually there are 3 fundamental steps that are essential when creating a CI/CD pipeline: build, security scanning, and publish. Let’s explore how we can create these steps using the Dagger Go SDK.
Daggerizing the Pipeline

Initializing Dagger Module
Let’s start by initializing our dagger module this is where our CI code will live.
dagger init --sdk=go --name=blog-ciThe following will create the .dagger directory with some boiler plate, I choose Go
for the sdk, but feel free
to choose any language from their available sdks
.
Build CI
Let’s start with the build step. Here, we generally want to specify the source of our project, the platform that we are building our container image for, and the tags we want to apply.
type ImageTags struct {
// The Semantic Version, i.e, v1.0.0
Version string `json:"version"`
// The SHA digest
SHA string `json:"sha"`
}// Build container from Dockerfile
func (m *BlogCi) BuildFromDockerfile(
// +defaultPath="/"
source *dagger.Directory,
platform dagger.Platform,
tags ImageTags,
// +default="http://localhost:8080/"
base_url string,
) *dagger.Container {
return source.DockerBuild(dagger.DirectoryDockerBuildOpts{
Platform: platform,
BuildArgs: []dagger.BuildArg{
dagger.BuildArg{Name: "BASE_URL", Value: base_url},
dagger.BuildArg{Name: "GIT_SHA", Value: tags.SHA},
dagger.BuildArg{Name: "VERSION", Value: tags.Version},
},
}).WithLabel("org.opencontainers.image.created", time.Now().UTC().Format(time.RFC3339))
}We define the BuildFromDockerfile that takes some parameters:
source: the directory where the Dockerfile is locatedplatform: the specific platform variant, i.e, linux/amd64tags: A struct that contains both semantic version and github shabase_url: Hugo specific config for the base url of the website
Note that I pass in the build arguments slice to our container such that we apply appropriate metadata labels. At the end, the BuildFromDockerfile function will return a dagger container correctly tagged and ready to be published to a container registry.
Using Pure Dagger to Build the Image
In this case, I use the native DockerBuild function to build the container from an existing Dockerfile. You can also define entirely in code
the exact same container starting from dag.Container().From("base-image") and applying the layers that way. Check the example, in the Dagger quickstart guide
Security Scanning with Trivy
Now that we have a way to return our container image, we want to make sure that it does not contain any critical or high vulnerabilities. This step is often overlooked, but in this modern era of AI-generated code, and with big players in image hardening going closed source , we certainly want to be more conscious of having a security first posture.
We’ll use trivy , a fantastic security scanner to find CVEs and misconfigurations in our container image. In the case we detect a critical or high vunerability in our image, we will halt the pipeline by returning an error.
// Scan Image Built for Vulnerabilities
func (m *BlogCi) ScanVulnerabilities(ctx context.Context, ctr *dagger.Container) error {
tarball := ctr.AsTarball()
trivy := dag.Container().From("aquasec/trivy:0.68.2").
WithMountedFile("/image.tar", tarball).
WithExec([]string{
"trivy",
"image",
"--input", "/image.tar",
"--severity", "CRITICAL,HIGH",
"--exit-code", "1", // signal critical/high vunerability found
"--format", "table",
})
output, err := trivy.Stdout(ctx)
if err != nil {
return fmt.Errorf("critical/high vunerabilities detected: %s", err.Error())
}
fmt.Printf("Trivy scan success - no critical or high vulnerabilities found\n%s", output)
return nil
}We take our container from the build step and create a tarball, which we then feed into the trivy CLI image to determine if we have critical/high vulnerabilities. If we find a vulnerability, the trivy container will have exit code 1.
Publish CI
Lastly, we can publish that container by providing the specified parameters.
1// Publish Docker image to registry
2func (m *BlogCi) PublishImage(ctx context.Context,
3 name string,
4 // +default="latest"
5 version string,
6 sha string,
7 // +default="ttl.sh"
8 registry string,
9 username string,
10 password *dagger.Secret,
11 // +optional
12 base_url string,
13 // +defaultPath="/"
14 source *dagger.Directory,
15) (string, error) {
16
17 url := "http://localhost:8080/"
18 if base_url != "" {
19 url = base_url
20 }
21
22 platforms := []dagger.Platform{
23 "linux/amd64",
24 "linux/arm64",
25 }
26 platformVariants := make([]*dagger.Container, 0, len(platforms))
27 for _, platform := range platforms {
28 ctr := m.BuildFromDockerfile(source, platform, ImageTags{Version: version, SHA: sha}, url)
29 if err := m.ScanVulnerabilities(ctx, ctr); err != nil {
30 return "", err
31 }
32 platformVariants = append(platformVariants, ctr)
33 }
34
35 imageName := fmt.Sprintf("%s/%s/%s:%s", registry, username, name, version)
36 ctr := dag.Container()
37
38 if registry != "ttl.sh" {
39 ctr = ctr.WithRegistryAuth(registry, username, password)
40 } else {
41 imageName = fmt.Sprintf("%s/%s-%.0f", registry, name, math.Floor(rand.Float64()*10000000))
42 }
43
44 return ctr.Publish(ctx, imageName, dagger.ContainerPublishOpts{PlatformVariants: platformVariants})
45}The logic from lines 38-42 helps us test the pipeline locally by publishing the container image to ttl.sh , and if we provide a different registry such as ghcr.io or dockerhub, it will apply the registry auth. This allows us to have faster feedback loops of the CI without having to go through the github runners.
Finishing the pipeline with GitHub Actions
Lastly, we can wrap our dagger call and have a simple github action that will run when we publish a tag as follows:
name: Publish Blog Image
on:
push:
tags:
- "**"
jobs:
publish:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
env:
NAME: kinho-blog
USERNAME: ${{github.repository_owner}}
SHA_TAG: ${{github.sha}}
SEMVER_TAG: ${{github.ref_name}}
BASE_URL: https://blog.manakin-koi.ts.net # image for tailnet
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 0
- uses: dagger/[email protected]
- name: Publish Blog Docker Image to ghcr
env:
PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: |
dagger call publish-image --registry=ghcr.io --name=$NAME --version=latest --sha=$SHA_TAG --base-url=$BASE_URL --username=$USERNAME --password=env:PASSWORD # latest
dagger call publish-image --registry=ghcr.io --name=$NAME --version=$SEMVER_TAG --sha=$SHA_TAG --base-url=$BASE_URL --username=$USERNAME --password=env:PASSWORD # semver
dagger call publish-image --registry=ghcr.io --name=$NAME --version=$SHA_TAG --sha=$SHA_TAG --base-url=$BASE_URL --username=$USERNAME --password=env:PASSWORD # sha```The action will trigger our full Dagger pipeline every time I create a new tag. Now, with the pipeline set I can push new versions of my blog with a tag, and I can use the generated container image in my kubernetes deployments, very cool!

