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:

```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.
Loading blog_post_recommendations...