When -ldflags Meets Pulumi: Why My Go Binary Suddenly Got Bigger

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

1. Simple but larger: stay with the Pulumi‑friendly form

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

  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.

Loading blog_post_recommendations...