When -ldflags Meets Pulumi: Why My Go Binary Suddenly Got Bigger
Today I tripped over a subtle interaction between Go’s -ldflags and Pulumi’s command provider.
The symptom was simple:
- A local
go build produced a ~72 MiB Linux binary.
- The same project, built through Pulumi using a
command:local:Command, produced an ~80 MiB binary.
Same code, same Go version, supposedly same flags—but different sizes. Here’s what was going on and how to fix it.
Baseline: the “correct” build
My reference build was:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
go build -ldflags "-s -w" -v -o application main.go
This:
- Cross‑compiles for Linux (
GOOS=linux, GOARCH=amd64, CGO_ENABLED=0).
- Uses
-ldflags "-s -w" to strip symbol table and DWARF debug info.
- Produces a smaller binary (around 72–73 MiB for this project).
I also had a Go helper that effectively did the same:
func BuildExecutable(pathExec string) error {
newEnv := os.Environ()
newEnv = append(newEnv, "GOOS=linux")
newEnv = append(newEnv, "GOARCH=amd64")
newEnv = append(newEnv, "CGO_ENABLED=0")
cmd := exec.Command("go", "build", "-ldflags", "-s -w", "-v", "-o", pathExec, "main.go")
cmd.Env = newEnv
return cmd.Run()
}
So far, so good.
The Pulumi build: same flags… or so I thought
In the Pulumi program, I used the command provider to build the binary:
buildCmdStr := fmt.Sprintf(
"go build -ldflags=-s -ldflags=-w -v -o %s main.go",
config.BuildLocalExecutableTempPath,
)
buildCmd, err := local.NewCommand(ctx, "build-app", &local.CommandArgs{
Create: pulumi.String(buildCmdStr),
Dir: pulumi.String("../.."),
Environment: pulumi.StringMap{
"GOOS": pulumi.String("linux"),
"GOARCH": pulumi.String("amd64"),
"CGO_ENABLED": pulumi.String("0"),
},
})
I assumed these two -ldflags invocations would effectively combine and pass -s -w to the linker.
That assumption was wrong.
Surprise: multiple -ldflags don’t behave like I expected
To verify what was going on, I built locally with two variants:
# Variant 1 – single -ldflags with both flags
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
go build -ldflags "-s -w" -v -o tmp/app_single main.go
# Variant 2 – two separate -ldflags flags
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
go build -ldflags="-s" -ldflags="-w" -v -o tmp/app_multi main.go
Results:
app_single (with -ldflags "-s -w") → smaller (~73 MiB).
app_multi (with -ldflags="-s" -ldflags="-w") → larger (~80 MiB).
Conclusion: in practice, the last -ldflags wins; they’re not just concatenated. That meant my Pulumi build was only getting -w, not -s.
So part of the size increase came from that.
Then I tried to fix the quoting… and hit Pulumi’s escaping
“Easy,” I thought. I’ll just make the Pulumi command use the good version:
buildCmdStr := fmt.Sprintf(
`go build -ldflags "-s -w" -v -o %s main.go`,
config.BuildLocalExecutableTempPath,
)
Locally, that’s fine. Under Pulumi, it blew up with:
invalid value "\"-s" for flag -ldflags: missing =<value> in <pattern>=<value>
The Pulumi logs showed the actual command being run as:
go build -ldflags \"-s -w\" -v -o tmp\application_deploy_... main.go
So somewhere between my Go string and the final process invocation, the quotes were being escaped again. By the time go saw them, the value of -ldflags was effectively \"-s, which is invalid.
I tried several variants:
`go build -ldflags "-s -w" ...`
`go build -ldflags -s -w ...`
`go build -ldflags=%s ...` with `"-s -w"`
All of them eventually ended up as some form of:
-ldflags \"-s -w\"
and failed with the same error.
Another variant I tried:
buildCmdStr := fmt.Sprintf(
`go build -ldflags \"-s -w\" -v -o %s main.go`,
config.BuildLocalExecutableTempPath,
)
became, in the Pulumi logs:
go build -ldflags \\\"-s -w\\\" ...
invalid value "\\\"-s" for flag -ldflags
which shows exactly how the inner quotes get escaped twice.
Key takeaway:
When you pass a command line as a single string through Pulumi’s command:local:Command on Windows, that string may be quoted/escaped again by the provider, which can break flags like -ldflags that expect a single, space‑separated value.
Confirming the environment was correct
Along the way, I also verified that the environment from Pulumi really was applied:
env := pulumi.StringMap{
"GOOS": pulumi.String("linux"),
"GOARCH": pulumi.String("amd64"),
"CGO_ENABLED": pulumi.String("0"),
}
buildCmdStr := fmt.Sprintf("go build ...")
buildCmd, _ := local.NewCommand(ctx, "build-app", &local.CommandArgs{
Create: pulumi.String(buildCmdStr),
Dir: pulumi.String("../.."),
Environment: env,
})
Then I intentionally broke it:
env["GOOS"] = pulumi.String("this_should_fail")
Pulumi immediately failed with:
go: unsupported GOOS/GOARCH pair this_should_fail/amd64
So I know:
Environment is respected.
- The only remaining difference is how the command string is parsed and passed to
go.
Practical solutions
If you’re fine with a slightly larger binary, you can just accept:
buildCmdStr := fmt.Sprintf(
"go build -ldflags=-s -ldflags=-w -v -o %s main.go",
config.BuildLocalExecutableTempPath,
)
It’s stable and works well with Pulumi’s quoting behavior, at the cost of a few extra megabytes.
2. More work, smaller binary: use a build script
If you really care about squeezing out the last few MB and want -s -w applied properly, the cleanest option is to move go build into a script and have Pulumi call that script.
Example (Windows, batch file):
cmd/pulumi/build_app.bat:
@echo off
setlocal
set GOOS=linux
set GOARCH=amd64
set CGO_ENABLED=0
REM %1 is the output path
go build -ldflags "-s -w" -v -o %1 main.go
endlocal
Then in the Pulumi program:
func createBuildCommand(ctx *pulumi.Context, config Config) (*local.Command, error) {
buildCmdStr := fmt.Sprintf(
`cmd /C build_app.bat %s`,
config.BuildLocalExecutableTempPath,
)
buildCmd, err := local.NewCommand(ctx, "build-app", &local.CommandArgs{
Create: pulumi.String(buildCmdStr),
Dir: pulumi.String("../.."), // project root
// no need for Environment: the batch sets GOOS/GOARCH/CGO_ENABLED
})
if err != nil {
return nil, err
}
return buildCmd, nil
}
Here:
- Pulumi just runs
cmd /C build_app.bat ....
- The batch file controls
go build -ldflags "-s -w", so there’s only one layer of quoting.
- The resulting binary matches the locally tested, smaller build.
Lessons learned
-
Don’t assume multiple -ldflags calls are merged.
In practice, the last one can override the earlier one, and you may silently drop some linker options.
-
Pulumi command:local:Command may re‑quote your command string.
On Windows in particular, inner quotes like " -s -w " can become \"-s -w\" by the time they hit go, which breaks flags that take a composite value.
-
When in doubt, test the exact command that Pulumi runs.
pulumi up --logtostderr -v=9 is very helpful—you can see exactly what’s being passed to the provider and what finally reaches the shell.
-
For complex command lines, scripts beat inline strings.
Let Pulumi call cmd /C script.bat or a shell script, and keep your tricky quoting (like -ldflags "-s -w") inside that script where you control the environment and argument parsing.
If you’re using Pulumi with Go and care about binary size, it’s worth double‑checking how -ldflags actually gets to go. The difference between "-s -w" and re‑quoted \"-s -w\" is only a couple of characters—but in my case it was also 7–8 MiB of unnecessary binary bloat.