Replace a Dockerfile with Go (or Python, or Node.js)
Introduction
This guide explains how to use a Dagger SDK to perform all the same operations that you would typically perform with a Dockerfile, except using Go, Python or Node.js. You will learn how to:
- Create a Dagger client
- Write a Dagger pipeline to:
- Configure a container with all required dependencies and environment variables
- Download and build the application source code in the container
- Set the container entrypoint
- Publish the built container image to Docker Hub
- Test the Dagger pipeline locally
Requirements
This guide assumes that:
- You have a Go, Python or Node.js development environment. If not, install Go, Python or Node.js.
- You have Docker installed and running on the host system. If not, install Docker.
- You have a Dagger SDK installed for one of the above languages. If not, follow the installation instructions for the Dagger Go, Python or Node.js SDK.
- You have the Dagger CLI installed in your development environment. If not, install the Dagger CLI.
- You have a Docker Hub account. If not, register for a Docker Hub account.
Step 1: Understand the source Dockerfile
To illustrate the process, this guide replicates the build process for the popular open source Memcached caching system using Dagger. It uses the Dockerfile and entrypoint script for the official Docker Hub Memcached image.
Begin by reviewing the source Dockerfile and corresponding entrypoint script to understand how it works. This Dockerfile is current at the time of writing and is available under the BSD 3-Clause License.
Broadly, this Dockerfile performs the following steps:
- It starts from a base
alpine
container image. - It adds a
memcache
user and group with defined IDs. - It sets environment variables for the Memcached version (
MEMCACHED_VERSION
) and commit hash (MEMCACHED_SHA1
). - It installs dependencies in the container.
- It downloads the source code archive for the specified version of Memcached, checks the commit hash and extracts the source code into a directory.
- It configures, builds, tests and installs Memcached from source using
make
. - It copies and sets the container entrypoint script.
- It configures the image to run as the
memcache
user.
Step 2: Replicate the Dockerfile using a Dagger pipeline
The Dagger SDK enables you to develop a CI/CD pipeline in one of the supported languages (Go, Python or Node.js) to achieve the same result as using a Dockerfile.
- Go
- Node.js
- Python
To see how this works, add the following code to your Go module as main.go
. Replace the DOCKER-HUB-USERNAME placeholder with your Docker Hub username.
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
const (
nproc = "1"
gnuArch = "arm64"
publishAddr = "DOCKER-HUB-USERNAME/my-memcached"
)
func main() {
ctx := context.Background()
// create a Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// set the base container
// set environment variables
memcached := client.Container().
From("alpine:3.17").
WithExec([]string{"addgroup", "-g", "11211", "memcache"}).
WithExec([]string{"adduser", "-D", "-u", "1121", "-G", "memcache", "memcache"}).
WithExec([]string{"apk", "add", "--no-cache", "libsasl"}).
WithEnvVariable("MEMCACHED_VERSION", "1.6.17").
WithEnvVariable("MEMCACHED_SHA1", "e25639473e15f1bd9516b915fb7e03ab8209030f")
// add dependencies to the container
memcached = setDependencies(memcached)
// add source code to the container
memcached = downloadMemcached(memcached)
// build the application
memcached = buildMemcached(memcached)
// set the container entrypoint
entrypoint := client.Host().Directory(".").File("docker-entrypoint.sh")
memcached = memcached.
WithFile("/usr/local/bin/docker-entrypoint.sh", entrypoint).
WithExec([]string{"ln", "-s", "usr/local/bin/docker-entrypoint.sh", "/entrypoint.sh"}).
WithEntrypoint([]string{"docker-entrypoint.sh"}).
WithUser("memcache").
WithDefaultArgs([]string{"memcached"})
// publish the container image
addr, err := memcached.Publish(ctx, publishAddr)
if err != nil {
panic(err)
}
fmt.Printf("Published to %s", addr)
}
func setDependencies(container *dagger.Container) *dagger.Container {
return container.
WithExec([]string{
"apk",
"add",
"--no-cache",
"--virtual",
".build-deps",
"ca-certificates",
"coreutils",
"cyrus-sasl-dev",
"gcc",
"libc-dev",
"libevent-dev",
"linux-headers",
"make",
"openssl",
"openssl-dev",
"perl",
"perl-io-socket-ssl",
"perl-utils",
})
}
func downloadMemcached(container *dagger.Container) *dagger.Container {
return container.
WithExec([]string{"sh", "-c", "wget -O memcached.tar.gz https://memcached.org/files/memcached-$MEMCACHED_VERSION.tar.gz"}).
WithExec([]string{"sh", "-c", "echo \"$MEMCACHED_SHA1 memcached.tar.gz\" | sha1sum -c -"}).
WithExec([]string{"mkdir", "-p", "/usr/src/memcached"}).
WithExec([]string{"tar", "-xvf", "memcached.tar.gz", "-C", "/usr/src/memcached", "--strip-components=1"}).
WithExec([]string{"rm", "memcached.tar.gz"})
}
func buildMemcached(container *dagger.Container) *dagger.Container {
return container.
WithWorkdir("/usr/src/memcached").
WithExec([]string{
"./configure",
fmt.Sprintf("--build=%s", gnuArch),
"--enable-extstore",
"--enable-sasl",
"--enable-sasl-pwdb",
"--enable-tls",
}).
WithExec([]string{"make", "-j", nproc}).
WithExec([]string{"make", "test", fmt.Sprintf("PARALLEL=%s", nproc)}).
WithExec([]string{"make", "install"}).
WithWorkdir("/usr/src/memcached").
WithExec([]string{"rm", "-rf", "/usr/src/memcached"}).
WithExec([]string{
"sh",
"-c",
"apk add --no-network --virtual .memcached-rundeps $( scanelf --needed --nobanner --format '%n#p' --recursive /usr/local | tr ',' '\n' | sort -u | awk 'system(\"[ -e /usr/local/lib/\" $1 \" ]\") == 0 { next } { print \"so:\" $1 }')",
}).
WithExec([]string{"apk", "del", "--no-network", ".build-deps"}).
WithExec([]string{"memcached", "-V"})
}
There's a lot going on here, so let's step through it in detail:
- The Go CI pipeline imports the Dagger SDK and defines a
main()
function. Themain()
function creates a Dagger client withdagger.Connect()
. This client provides an interface for executing commands against the Dagger engine. - It initializes a new container from a base image with the client's
Container().From()
method and returns a newContainer
struct. In this case, the base image is thealpine:3.17
image. - It calls the
WithExec()
method to define theadduser
,addgroup
andapk add
commands for execution, and theWithEnvVariable()
method to set theMEMCACHED_VERSION
andMEMCACHED_SHA1
container environment variables. - It calls a custom
setDependencies()
function, which internally usesWithExec()
to define theapk add
command that installs all the required dependencies to build and test Memcached in the container. - It calls a custom
downloadMemcached()
function, which internally usesWithExec()
to define thewget
,tar
and related commands required to download, verify and extract the Memcached source code archive in the container at the/usr/src/memcached
container path. - It calls a custom
buildMemcached()
function, which internally usesWithExec()
to define theconfigure
andmake
commands required to build, test and install Memcached in the container. ThebuildMemcached()
function also takes care of deleting the source code directory at/usr/src/memcached
in the container and executingmemcached -V
to output the version string to the console. - It updates the container filesystem to include the entrypoint script from the host using
WithFile()
and specifies it as the command to be executed when the container runs usingWithEntrypoint()
. - Finally, it calls the
Container.Publish()
method, which executes the entire pipeline described above and publishes the resulting container image to Docker Hub.
To see how this works, create a file named index.mjs
and add the following code to it. Replace the DOCKER-HUB-USERNAME placeholder with your Docker Hub username.
import { connect } from "@dagger.io/dagger"
const NPROC = "1"
const GNU_ARCH = "arm64"
const PUBLISH_ADDRESS = "DOCKER-HUB-USERNAME/my-memcached"
connect(
async (client) => {
// set the base container
// set environment variables
let memcached = client
.container()
.from("alpine:3.17")
.withExec(["addgroup", "-g", "11211", "memcache"])
.withExec(["adduser", "-D", "-u", "1121", "-G", "memcache", "memcache"])
.withExec(["apk", "add", "--no-cache", "libsasl"])
.withEnvVariable("MEMCACHED_VERSION", "1.6.17")
.withEnvVariable(
"MEMCACHED_SHA1",
"e25639473e15f1bd9516b915fb7e03ab8209030f",
)
// add dependencies to the container
memcached = setDependencies(memcached)
// add source code to the container
memcached = downloadMemcached(memcached)
// build the application
memcached = buildMemcached(memcached)
// set the container entrypoint
memcached = memcached
.withFile(
"/usr/local/bin/docker-entrypoint.sh",
client.host().directory(".").file("docker-entrypoint.sh"),
)
.withExec([
"ln",
"-s",
"usr/local/bin/docker-entrypoint.sh",
"/entrypoint.sh", // backwards compat
])
.withEntrypoint(["docker-entrypoint.sh"])
.withUser("memcache")
.withDefaultArgs(["memcached"])
// publish the container image
const addr = await memcached.publish(PUBLISH_ADDRESS)
console.log(`Published to ${addr}`)
},
{ LogOutput: process.stderr },
)
function setDependencies(container) {
return container.withExec([
"apk",
"add",
"--no-cache",
"--virtual",
".build-deps",
"ca-certificates",
"coreutils",
"cyrus-sasl-dev",
"gcc",
"libc-dev",
"libevent-dev",
"linux-headers",
"make",
"openssl",
"openssl-dev",
"perl",
"perl-io-socket-ssl",
"perl-utils",
])
}
function downloadMemcached(container) {
const url = "https://memcached.org/files/memcached-$MEMCACHED_VERSION.tar.gz"
return container
.withExec(["sh", "-c", `wget -O memcached.tar.gz ${url}`])
.withExec([
"sh",
"-c",
'echo "$MEMCACHED_SHA1 memcached.tar.gz" | sha1sum -c -',
])
.withExec(["mkdir", "-p", "/usr/src/memcached"])
.withExec([
"tar",
"-xvf",
"memcached.tar.gz",
"-C",
"/usr/src/memcached",
"--strip-components=1",
])
.withExec(["rm", "memcached.tar.gz"])
}
function buildMemcached(container) {
return container
.withWorkdir("/usr/src/memcached")
.withExec([
"./configure",
`--build=${GNU_ARCH}`,
"--enable-extstore",
"--enable-sasl",
"--enable-sasl-pwdb",
"--enable-tls",
])
.withExec(["make", "-j", NPROC])
.withExec(["make", "test", `PARALLEL=${NPROC}`])
.withExec(["make", "install"])
.withWorkdir("/usr/src/memcached")
.withExec(["rm", "-rf", "/usr/src/memcached"])
.withExec([
"sh",
"-c",
"apk add --no-network --virtual .memcached-rundeps $( scanelf --needed --nobanner --format '%n#p' --recursive /usr/local | tr ',' '\n' | sort -u | awk 'system(\"[ -e /usr/local/lib/\" $1 \" ]\") == 0 { next } { print \"so:\" $1 }')",
])
.withExec(["apk", "del", "--no-network", ".build-deps"])
.withExec(["memcached", "-V"])
}
There's a lot going on here, so let's step through it in detail:
- The Node.js CI pipeline imports the Dagger SDK and creates a Dagger client with
connect()
. This client provides an interface for executing commands against the Dagger engine. - It initializes a new container from a base image with the client's
container().from()
method and returns a newContainer
object. In this case, the base image is thealpine:3.17
image. - It calls the
withExec()
method to define theadduser
,addgroup
andapk add
commands for execution, and thewithEnvVariable()
method to set theMEMCACHED_VERSION
andMEMCACHED_SHA1
container environment variables. - It calls a custom
setDependencies()
function, which internally useswithExec()
to define theapk add
command that installs all the required dependencies to build and test Memcached in the container. - It calls a custom
downloadMemcached()
function, which internally useswithExec()
to define thewget
,tar
and related commands required to download, verify and extract the Memcached source code archive in the container at the/usr/src/memcached
container path. - It calls a custom
buildMemcached()
function, which internally useswithExec()
to define theconfigure
andmake
commands required to build, test and install Memcached in the container. ThebuildMemcached()
function also takes care of deleting the source code directory at/usr/src/memcached
in the container and executingmemcached -V
to output the version string to the console. - It updates the container filesystem to include the entrypoint script from the host using
withFile()
and specifies it as the command to be executed when the container runs usingwithEntrypoint()
. ThewithDefaultArgs()
methods specifies the entrypoint arguments. - Finally, it calls the
Container.publish()
method, which executes the entire pipeline described above and publishes the resulting container image to Docker Hub.
To see how this works, create a file named main.py
and add the following code to it. Replace the DOCKER-HUB-USERNAME placeholder with your Docker Hub username.
import sys
import anyio
import dagger
NPROC = "1"
GNU_ARCH = "arm64"
PUBLISH_ADDRESS = "DOCKER-HUB-USERNAME/my-memcached"
async def main():
config = dagger.Config(log_output=sys.stderr)
# create a Dagger client
async with dagger.Connection(config) as client:
# set the base container
# set environment variables
memcached = (
client.container()
.from_("alpine:3.17")
.with_exec(["addgroup", "-g", "11211", "memcache"])
.with_exec(["adduser", "-D", "-u", "1121", "-G", "memcache", "memcache"])
.with_exec(["apk", "add", "--no-cache", "libsasl"])
.with_env_variable("MEMCACHED_VERSION", "1.6.17")
.with_env_variable(
"MEMCACHED_SHA1",
"e25639473e15f1bd9516b915fb7e03ab8209030f",
)
)
# add dependencies to the container
memcached = set_dependencies(memcached)
# add source code to the container
memcached = download_memcached(memcached)
# build the application
memcached = build_memcached(memcached)
# set the container entrypoint
memcached = (
memcached.with_file(
"/usr/local/bin/docker-entrypoint.sh",
client.host().directory(".").file("docker-entrypoint.sh"),
)
.with_exec(
[
"ln",
"-s",
"usr/local/bin/docker-entrypoint.sh",
"/entrypoint.sh", # backwards compat
]
)
.with_entrypoint(["docker-entrypoint.sh"])
.with_user("memcache")
.with_default_args(["memcached"])
)
# publish the container image
addr = await memcached.publish(PUBLISH_ADDRESS)
print(f"Published to {addr}")
def set_dependencies(container: dagger.Container) -> dagger.Container:
return container.with_exec(
[
"apk",
"add",
"--no-cache",
"--virtual",
".build-deps",
"ca-certificates",
"coreutils",
"cyrus-sasl-dev",
"gcc",
"libc-dev",
"libevent-dev",
"linux-headers",
"make",
"openssl",
"openssl-dev",
"perl",
"perl-io-socket-ssl",
"perl-utils",
]
)
def download_memcached(container: dagger.Container) -> dagger.Container:
url = "https://memcached.org/files/memcached-$MEMCACHED_VERSION.tar.gz"
return (
container.with_exec(["sh", "-c", f"wget -O memcached.tar.gz {url}"])
.with_exec(
["sh", "-c", 'echo "$MEMCACHED_SHA1 memcached.tar.gz" | sha1sum -c -']
)
.with_exec(["mkdir", "-p", "/usr/src/memcached"])
.with_exec(
[
"tar",
"-xvf",
"memcached.tar.gz",
"-C",
"/usr/src/memcached",
"--strip-components=1",
]
)
.with_exec(["rm", "memcached.tar.gz"])
)
def build_memcached(container: dagger.Container) -> dagger.Container:
return (
container.with_workdir("/usr/src/memcached")
.with_exec(
[
"./configure",
f"--build={GNU_ARCH}",
"--enable-extstore",
"--enable-sasl",
"--enable-sasl-pwdb",
"--enable-tls",
]
)
.with_exec(["make", "-j", NPROC])
.with_exec(["make", "test", f"PARALLEL={NPROC}"])
.with_exec(["make", "install"])
.with_workdir("/usr/src/memcached")
.with_exec(["rm", "-rf", "/usr/src/memcached"])
.with_exec(
[
"sh",
"-c",
(
"apk add --no-network --virtual .memcached-rundeps $( scanelf"
" --needed --nobanner --format '%n#p' --recursive /usr/local | tr"
" ',' '\n' | sort -u | awk 'system(\"[ -e /usr/local/lib/\" $1 \""
' ]") == 0 { next } { print "so:" $1 }\')'
),
]
)
.with_exec(["apk", "del", "--no-network", ".build-deps"])
.with_exec(["memcached", "-V"])
)
anyio.run(main)
There's a lot going on here, so let's step through it in detail:
- The Python CI pipeline imports the Dagger SDK and defines a
main()
function. Themain()
function creates a Dagger client withdagger.Connection()
. This client provides an interface for executing commands against the Dagger engine. - It initializes a new container from a base image with the client's
container().from_()
method and returns a newContainer
. In this case, the base image is thealpine:3.17
image. - It calls the
with_exec()
method to define theadduser
,addgroup
andapk add
commands for execution, and thewith_env_variable()
method to set theMEMCACHED_VERSION
andMEMCACHED_SHA1
container environment variables. - It calls a custom
set_dependencies()
function, which internally useswith_exec()
to define theapk add
command that installs all the required dependencies to build and test Memcached in the container. - It calls a custom
download_memcached()
function, which internally useswith_exec()
to define thewget
,tar
and related commands required to download, verify and extract the Memcached source code archive in the container at the/usr/src/memcached
container path. - It calls a custom
build_memcached()
function, which internally useswith_exec()
to define theconfigure
andmake
commands required to build, test and install Memcached in the container. Thebuild_memcached()
function also takes care of deleting the source code directory at/usr/src/memcached
in the container and executingmemcached -V
to output the version string to the console. - It updates the container filesystem to include the entrypoint script from the host using
with_file()
and specifies it as the command to be executed when the container runs usingwith_entrypoint()
. Thewith_default_args()
methods specifies the entrypoint arguments. - Finally, it calls the
Container.publish()
method, which executes the entire pipeline described above and publishes the resulting container image to Docker Hub.
Like the source Dockerfile, this pipeline assumes that the entrypoint script exists in the current working directory on the host as docker-entrypoint.sh
. You can either create a custom entrypoint script, or use the entrypoint script from the Docker Hub Memcached image repository.
Step 3: Test the Dagger pipeline
Test the Dagger pipeline as follows:
- Go
- Node.js
- Python
-
Log in to Docker on the host:
docker login
infoThis step is necessary because Dagger relies on the host's Docker credentials and authorizations when publishing to remote registries.
-
Run the pipeline:
dagger run go run main.go
-
Log in to Docker on the host:
docker login
infoThis step is necessary because Dagger relies on the host's Docker credentials and authorizations when publishing to remote registries.
-
Run the pipeline:
dagger run node index.mjs
-
Log in to Docker on the host:
docker login
infoThis step is necessary because Dagger relies on the host's Docker credentials and authorizations when publishing to remote registries.
-
Run the pipeline:
dagger run python main.py
Verify that you have an entrypoint script on the host at ./docker-entrypoint.sh
before running the Dagger pipeline.
The dagger run
command executes the script in a Dagger session and displays live progress. This process will take some time. At the end of the process, the built container image is published on Docker Hub and a message similar to the one below appears in the console output:
Published to docker.io/.../my-memcached@sha256:692....
Browse to your Docker Hub registry to see the published Memcached container image.
Conclusion
This tutorial introduced you to the Dagger SDKs. By replacing a Dockerfile with native code, it demonstrated how Dagger SDKs contain everything you need to develop CI/CD pipelines in your favorite language and run them on any OCI-compatible container runtime.
The advantage of this approach is that it allows you to use powerful native language features, such as (where applicable) static typing, concurrency, programming structures such as loops and conditionals, and built-in testing, to create powerful CI/CD tooling for your project or organization.
Use the API Key Concepts page and the Go, Node.js and Python SDK References to learn more about Dagger.