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:
```text
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:
```go
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:
```go
env["GOOS"] = pulumi.String("this_should_fail")
```
Pulumi immediately failed with:
```text
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
### 1. Simple but larger: stay with the Pulumi‑friendly form
If you’re fine with a slightly larger binary, you can just accept:
```go
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`:
```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:
```go
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
1. **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.
2. **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.
3. **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.
4. **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.