diff --git a/backend/controller/admin/admin.go b/backend/controller/admin/admin.go
index 7590a2175e..e33797aa16 100644
--- a/backend/controller/admin/admin.go
+++ b/backend/controller/admin/admin.go
@@ -99,7 +99,7 @@ func configProviderKey(p *ftlv1.ConfigProvider) string {
 // ConfigSet sets the configuration at the given ref to the provided value.
 func (s *AdminService) ConfigSet(ctx context.Context, req *connect.Request[ftlv1.SetConfigRequest]) (*connect.Response[ftlv1.SetConfigResponse], error) {
 	pkey := configProviderKey(req.Msg.Provider)
-	err := s.cm.Set(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), string(req.Msg.Value))
+	err := s.cm.SetJSON(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), req.Msg.Value)
 	if err != nil {
 		return nil, err
 	}
@@ -185,7 +185,7 @@ func secretProviderKey(p *ftlv1.SecretProvider) string {
 // SecretSet sets the secret at the given ref to the provided value.
 func (s *AdminService) SecretSet(ctx context.Context, req *connect.Request[ftlv1.SetSecretRequest]) (*connect.Response[ftlv1.SetSecretResponse], error) {
 	pkey := secretProviderKey(req.Msg.Provider)
-	err := s.sm.Set(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), string(req.Msg.Value))
+	err := s.sm.SetJSON(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), req.Msg.Value)
 	if err != nil {
 		return nil, err
 	}
diff --git a/cmd/ftl/cmd_config.go b/cmd/ftl/cmd_config.go
index 1679410abf..b818adc095 100644
--- a/cmd/ftl/cmd_config.go
+++ b/cmd/ftl/cmd_config.go
@@ -93,7 +93,7 @@ func (s *configGetCmd) Run(ctx context.Context, adminClient admin.Client) error
 		Ref: configRefFromRef(s.Ref),
 	}))
 	if err != nil {
-		return err
+		return fmt.Errorf("failed to get config: %w", err)
 	}
 	fmt.Printf("%s\n", resp.Msg.Value)
 	return nil
@@ -117,18 +117,23 @@ func (s *configSetCmd) Run(ctx context.Context, scmd *configCmd, adminClient adm
 		}
 	}
 
-	var configValue []byte
+	var configJSON json.RawMessage
 	if s.JSON {
-		if err := json.Unmarshal(config, &configValue); err != nil {
+		var jsonValue any
+		if err := json.Unmarshal(config, &jsonValue); err != nil {
 			return fmt.Errorf("config is not valid JSON: %w", err)
 		}
+		configJSON = config
 	} else {
-		configValue = config
+		configJSON, err = json.Marshal(string(config))
+		if err != nil {
+			return fmt.Errorf("failed to encode config as JSON: %w", err)
+		}
 	}
 
 	req := &ftlv1.SetConfigRequest{
 		Ref:   configRefFromRef(s.Ref),
-		Value: configValue,
+		Value: configJSON,
 	}
 	if provider, ok := scmd.provider().Get(); ok {
 		req.Provider = &provider
diff --git a/cmd/ftl/cmd_secret.go b/cmd/ftl/cmd_secret.go
index 197dd94745..75a58c4588 100644
--- a/cmd/ftl/cmd_secret.go
+++ b/cmd/ftl/cmd_secret.go
@@ -95,7 +95,7 @@ func (s *secretGetCmd) Run(ctx context.Context, adminClient admin.Client) error
 		Ref: configRefFromRef(s.Ref),
 	}))
 	if err != nil {
-		return err
+		return fmt.Errorf("failed to get secret: %w", err)
 	}
 	fmt.Printf("%s\n", resp.Msg.Value)
 	return nil
@@ -124,18 +124,23 @@ func (s *secretSetCmd) Run(ctx context.Context, scmd *secretCmd, adminClient adm
 		}
 	}
 
-	var secretValue []byte
+	var secretJSON json.RawMessage
 	if s.JSON {
-		if err := json.Unmarshal(secret, &secretValue); err != nil {
+		var jsonValue any
+		if err := json.Unmarshal(secret, &jsonValue); err != nil {
 			return fmt.Errorf("secret is not valid JSON: %w", err)
 		}
+		secretJSON = secret
 	} else {
-		secretValue = secret
+		secretJSON, err = json.Marshal(string(secret))
+		if err != nil {
+			return fmt.Errorf("failed to encode secret as JSON: %w", err)
+		}
 	}
 
 	req := &ftlv1.SetSecretRequest{
 		Ref:   configRefFromRef(s.Ref),
-		Value: secretValue,
+		Value: secretJSON,
 	}
 	if provider, ok := scmd.provider().Get(); ok {
 		req.Provider = &provider
diff --git a/cmd/ftl/main.go b/cmd/ftl/main.go
index 9ef5764794..a734f86c71 100644
--- a/cmd/ftl/main.go
+++ b/cmd/ftl/main.go
@@ -84,14 +84,25 @@ func main() {
 	logger := log.Configure(os.Stderr, cli.LogConfig)
 	ctx = log.ContextWithLogger(ctx, logger)
 
-	config, err := projectconfig.Load(ctx, cli.ConfigFlag)
+	configPath := cli.ConfigFlag
+	if configPath == "" {
+		var ok bool
+		configPath, ok = projectconfig.DefaultConfigPath().Get()
+		if !ok {
+			kctx.Fatalf("could not determine default config path, either place an ftl-project.toml file in the root of your project, use --config=FILE, or set the FTL_CONFIG envar")
+		}
+	}
+
+	os.Setenv("FTL_CONFIG", configPath)
+
+	config, err := projectconfig.Load(ctx, configPath)
 	if err != nil && !errors.Is(err, os.ErrNotExist) {
 		kctx.Fatalf(err.Error())
 	}
 	kctx.Bind(config)
 
-	sr := cf.ProjectConfigResolver[cf.Secrets]{Config: cli.ConfigFlag}
-	cr := cf.ProjectConfigResolver[cf.Configuration]{Config: cli.ConfigFlag}
+	sr := cf.ProjectConfigResolver[cf.Secrets]{Config: configPath}
+	cr := cf.ProjectConfigResolver[cf.Configuration]{Config: configPath}
 	kctx.BindTo(sr, (*cf.Resolver[cf.Secrets])(nil))
 	kctx.BindTo(cr, (*cf.Resolver[cf.Configuration])(nil))
 
diff --git a/common/configuration/manager.go b/common/configuration/manager.go
index 97c4d5df72..3763f71f24 100644
--- a/common/configuration/manager.go
+++ b/common/configuration/manager.go
@@ -1,6 +1,7 @@
 package configuration
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"errors"
@@ -108,18 +109,26 @@ func (m *Manager[R]) availableProviderKeys() []string {
 	return keys
 }
 
-// Set a configuration value.
+// Set a configuration value, encoding "value" as JSON before storing it.
 func (m *Manager[R]) Set(ctx context.Context, pkey string, ref Ref, value any) error {
+	data, err := json.Marshal(value)
+	if err != nil {
+		return err
+	}
+	return m.SetJSON(ctx, pkey, ref, data)
+}
+
+// SetJSON sets a configuration value using raw JSON data.
+func (m *Manager[R]) SetJSON(ctx context.Context, pkey string, ref Ref, value json.RawMessage) error {
+	if err := checkJSON(value); err != nil {
+		return fmt.Errorf("invalid value for %s, must be JSON: %w", m.resolver.Role(), err)
+	}
 	provider, ok := m.providers[pkey]
 	if !ok {
 		pkeys := strings.Join(m.availableProviderKeys(), ", ")
 		return fmt.Errorf("no provider for key %q, specify one of: %s", pkey, pkeys)
 	}
-	data, err := json.Marshal(value)
-	if err != nil {
-		return err
-	}
-	key, err := provider.Store(ctx, ref, data)
+	key, err := provider.Store(ctx, ref, value)
 	if err != nil {
 		return err
 	}
@@ -173,3 +182,10 @@ func (m *Manager[R]) Unset(ctx context.Context, pkey string, ref Ref) error {
 func (m *Manager[R]) List(ctx context.Context) ([]Entry, error) {
 	return m.resolver.List(ctx)
 }
+
+func checkJSON(data []byte) error {
+	dec := json.NewDecoder(bytes.NewReader(data))
+	dec.DisallowUnknownFields()
+	var v any
+	return dec.Decode(&v)
+}
diff --git a/common/projectconfig/integration_test.go b/common/projectconfig/integration_test.go
index a9ea3ee29a..1f95ed9a4b 100644
--- a/common/projectconfig/integration_test.go
+++ b/common/projectconfig/integration_test.go
@@ -11,6 +11,8 @@ import (
 	"github.com/alecthomas/assert/v2"
 
 	in "github.com/TBD54566975/ftl/integration"
+	"github.com/TBD54566975/ftl/internal/exec"
+	"github.com/TBD54566975/ftl/internal/log"
 )
 
 func TestCmdsCreateProjectTomlFilesIfNonexistent(t *testing.T) {
@@ -50,3 +52,33 @@ func TestConfigCmdWithoutController(t *testing.T) {
 		in.ExecWithExpectedOutput("\"value\"\n", "ftl", "config", "get", "key"),
 	)
 }
+
+func TestFindConfig(t *testing.T) {
+	checkConfig := func(subdir string) in.Action {
+		return func(t testing.TB, ic in.TestContext) {
+			in.Infof("Running ftl config list --values")
+			cmd := exec.Command(ic, log.Debug, filepath.Join(ic.WorkingDir(), subdir), "ftl", "config", "list", "--values")
+			cmd.Stdout = nil
+			cmd.Stderr = nil
+			output, err := cmd.CombinedOutput()
+			assert.NoError(t, err, "%s", output)
+			assert.Equal(t, "test = \"test\"\n", string(output))
+			in.Infof("Running ftl secret list --values")
+			cmd = exec.Command(ic, log.Debug, filepath.Join(ic.WorkingDir(), subdir), "ftl", "secret", "list", "--values")
+			cmd.Stdout = nil
+			cmd.Stderr = nil
+			output, err = cmd.CombinedOutput()
+			assert.NoError(t, err, "%s", output)
+			assert.Equal(t, "test = \"test\"\n", string(output))
+		}
+	}
+	in.RunWithoutController(t, "",
+		in.CopyModule("findconfig"),
+		checkConfig("findconfig"),
+		checkConfig("findconfig/subdir"),
+		in.SetEnv("FTL_CONFIG", func(ic in.TestContext) string {
+			return filepath.Join(ic.WorkingDir(), "findconfig", "ftl-project.toml")
+		}),
+		checkConfig("."),
+	)
+}
diff --git a/common/projectconfig/projectconfig.go b/common/projectconfig/projectconfig.go
index a0cde56aee..64f1d004b0 100644
--- a/common/projectconfig/projectconfig.go
+++ b/common/projectconfig/projectconfig.go
@@ -12,7 +12,6 @@ import (
 	"github.com/alecthomas/types/optional"
 
 	"github.com/TBD54566975/ftl"
-	"github.com/TBD54566975/ftl/internal"
 	"github.com/TBD54566975/ftl/internal/log"
 )
 
@@ -76,11 +75,26 @@ func DefaultConfigPath() optional.Option[string] {
 		}
 		return optional.Some(absPath)
 	}
-	gitRoot, ok := internal.GitRoot("").Get()
-	if !ok {
+	dir, err := os.Getwd()
+	if err != nil {
 		return optional.None[string]()
 	}
-	return optional.Some(filepath.Join(gitRoot, "ftl-project.toml"))
+	// Find the first ftl-project.toml file in the parent directories.
+	for {
+		path := filepath.Join(dir, "ftl-project.toml")
+		_, err := os.Stat(path)
+		if err == nil {
+			return optional.Some(path)
+		}
+		if !errors.Is(err, os.ErrNotExist) {
+			return optional.None[string]()
+		}
+		dir = filepath.Dir(dir)
+		if dir == "/" || dir == "." {
+			break
+		}
+	}
+	return optional.Some(filepath.Join(dir, "ftl-project.toml"))
 }
 
 // MaybeCreateDefault creates the ftl-project.toml file in the Git root if it
diff --git a/common/projectconfig/testdata/go/findconfig/findconfig.go b/common/projectconfig/testdata/go/findconfig/findconfig.go
new file mode 100644
index 0000000000..241e41ff43
--- /dev/null
+++ b/common/projectconfig/testdata/go/findconfig/findconfig.go
@@ -0,0 +1,21 @@
+package findconfig
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/TBD54566975/ftl/go-runtime/ftl" // Import the FTL SDK.
+)
+
+type EchoRequest struct {
+	Name ftl.Option[string] `json:"name"`
+}
+
+type EchoResponse struct {
+	Message string `json:"message"`
+}
+
+//ftl:verb
+func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) {
+	return EchoResponse{Message: fmt.Sprintf("Hello, %s!", req.Name.Default("anonymous"))}, nil
+}
diff --git a/common/projectconfig/testdata/go/findconfig/ftl-project.toml b/common/projectconfig/testdata/go/findconfig/ftl-project.toml
new file mode 100644
index 0000000000..08c214312d
--- /dev/null
+++ b/common/projectconfig/testdata/go/findconfig/ftl-project.toml
@@ -0,0 +1,5 @@
+[global]
+  [global.configuration]
+    test = "inline://InRlc3Qi"
+  [global.secrets]
+    test = "inline://InRlc3Qi"
diff --git a/common/projectconfig/testdata/go/findconfig/ftl.toml b/common/projectconfig/testdata/go/findconfig/ftl.toml
new file mode 100644
index 0000000000..b51629ecf7
--- /dev/null
+++ b/common/projectconfig/testdata/go/findconfig/ftl.toml
@@ -0,0 +1,2 @@
+module = "findconfig"
+language = "go"
diff --git a/common/projectconfig/testdata/go/findconfig/go.mod b/common/projectconfig/testdata/go/findconfig/go.mod
new file mode 100644
index 0000000000..8dc9832f18
--- /dev/null
+++ b/common/projectconfig/testdata/go/findconfig/go.mod
@@ -0,0 +1,46 @@
+module ftl/findconfig
+
+go 1.22.2
+
+toolchain go1.22.4
+
+require github.com/TBD54566975/ftl v1.1.5
+
+require (
+	connectrpc.com/connect v1.16.1 // indirect
+	connectrpc.com/grpcreflect v1.2.0 // indirect
+	connectrpc.com/otelconnect v0.7.0 // indirect
+	github.com/alecthomas/concurrency v0.0.2 // indirect
+	github.com/alecthomas/participle/v2 v2.1.1 // indirect
+	github.com/alecthomas/types v0.16.0 // indirect
+	github.com/alessio/shellescape v1.4.2 // indirect
+	github.com/danieljoos/wincred v1.2.0 // indirect
+	github.com/go-logr/logr v1.4.2 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/godbus/dbus/v5 v5.1.0 // indirect
+	github.com/jackc/pgpassfile v1.0.0 // indirect
+	github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+	github.com/jackc/pgx/v5 v5.6.0 // indirect
+	github.com/jackc/puddle/v2 v2.2.1 // indirect
+	github.com/jpillora/backoff v1.0.0 // indirect
+	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/multiformats/go-base36 v0.2.0 // indirect
+	github.com/puzpuzpuz/xsync/v3 v3.1.0 // indirect
+	github.com/swaggest/jsonschema-go v0.3.70 // indirect
+	github.com/swaggest/refl v1.3.0 // indirect
+	github.com/zalando/go-keyring v0.2.5 // indirect
+	go.opentelemetry.io/otel v1.27.0 // indirect
+	go.opentelemetry.io/otel/metric v1.27.0 // indirect
+	go.opentelemetry.io/otel/trace v1.27.0 // indirect
+	golang.org/x/crypto v0.24.0 // indirect
+	golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect
+	golang.org/x/mod v0.18.0 // indirect
+	golang.org/x/net v0.26.0 // indirect
+	golang.org/x/sync v0.7.0 // indirect
+	golang.org/x/sys v0.21.0 // indirect
+	golang.org/x/text v0.16.0 // indirect
+	google.golang.org/protobuf v1.34.2 // indirect
+)
+
+replace github.com/TBD54566975/ftl => /Users/alec/dev/ftl
diff --git a/common/projectconfig/testdata/go/findconfig/go.sum b/common/projectconfig/testdata/go/findconfig/go.sum
new file mode 100644
index 0000000000..4d35e3e659
--- /dev/null
+++ b/common/projectconfig/testdata/go/findconfig/go.sum
@@ -0,0 +1,142 @@
+connectrpc.com/connect v1.16.1 h1:rOdrK/RTI/7TVnn3JsVxt3n028MlTRwmK5Q4heSpjis=
+connectrpc.com/connect v1.16.1/go.mod h1:XpZAduBQUySsb4/KO5JffORVkDI4B6/EYPi7N8xpNZw=
+connectrpc.com/grpcreflect v1.2.0 h1:Q6og1S7HinmtbEuBvARLNwYmTbhEGRpHDhqrPNlmK+U=
+connectrpc.com/grpcreflect v1.2.0/go.mod h1:nwSOKmE8nU5u/CidgHtPYk1PFI3U9ignz7iDMxOYkSY=
+connectrpc.com/otelconnect v0.7.0 h1:ZH55ZZtcJOTKWWLy3qmL4Pam4RzRWBJFOqTPyAqCXkY=
+connectrpc.com/otelconnect v0.7.0/go.mod h1:Bt2ivBymHZHqxvo4HkJ0EwHuUzQN6k2l0oH+mp/8nwc=
+github.com/TBD54566975/scaffolder v1.0.0 h1:QUFSy2wVzumLDg7IHcKC6AP+IYyqWe9Wxiu72nZn5qU=
+github.com/TBD54566975/scaffolder v1.0.0/go.mod h1:auVpczIbOAdIhYDVSruIw41DanxOKB9bSvjf6MEl7Fs=
+github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
+github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/concurrency v0.0.2 h1:Q3kGPtLbleMbH9lHX5OBFvJygfyFw29bXZKBg+IEVuo=
+github.com/alecthomas/concurrency v0.0.2/go.mod h1:GmuQb/iHX7mbNtPlC/WDzEFxDMB0HYFer2Qda9QTs7w=
+github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8=
+github.com/alecthomas/participle/v2 v2.1.1/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c=
+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/alecthomas/types v0.16.0 h1:o9+JSwCRB6DDaWDeR/Mg7v/zh3R+MlknM6DrnDyY7U0=
+github.com/alecthomas/types v0.16.0/go.mod h1:Tswm0qQpjpVq8rn70OquRsUtFxbQKub/8TMyYYGI0+k=
+github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0=
+github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
+github.com/bool64/dev v0.2.34 h1:P9n315P8LdpxusnYQ0X7MP1CZXwBK5ae5RZrd+GdSZE=
+github.com/bool64/dev v0.2.34/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
+github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
+github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs=
+github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
+github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
+github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
+github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
+github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
+github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
+github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/puzpuzpuz/xsync/v3 v3.1.0 h1:EewKT7/LNac5SLiEblJeUu8z5eERHrmRLnMQL2d7qX4=
+github.com/puzpuzpuz/xsync/v3 v3.1.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
+github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
+github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ=
+github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU=
+github.com/swaggest/jsonschema-go v0.3.70 h1:8Vx5nm5t/6DBFw2+WC0/Vp1ZVe9/4mpuA0tuAe0wwCI=
+github.com/swaggest/jsonschema-go v0.3.70/go.mod h1:7N43/CwdaWgPUDfYV70K7Qm79tRqe/al7gLSt9YeGIE=
+github.com/swaggest/refl v1.3.0 h1:PEUWIku+ZznYfsoyheF97ypSduvMApYyGkYF3nabS0I=
+github.com/swaggest/refl v1.3.0/go.mod h1:3Ujvbmh1pfSbDYjC6JGG7nMgPvpG0ehQL4iNonnLNbg=
+github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
+github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
+github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
+github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
+github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
+github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
+go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg=
+go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ=
+go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik=
+go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak=
+go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI=
+go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A=
+go.opentelemetry.io/otel/sdk/metric v1.27.0 h1:5uGNOlpXi+Hbo/DRoI31BSb1v+OGcpv2NemcCrOL8gI=
+go.opentelemetry.io/otel/sdk/metric v1.27.0/go.mod h1:we7jJVrYN2kh3mVBlswtPU22K0SA+769l93J6bsyvqw=
+go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw=
+go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4=
+golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
+golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
+golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=
+golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
+golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
+golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
+golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
+golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.52.1 h1:uau0VoiT5hnR+SpoWekCKbLqm7v6dhRL3hI+NQhgN3M=
+modernc.org/libc v1.52.1/go.mod h1:HR4nVzFDSDizP620zcMCgjb1/8xk2lg5p/8yjfGv1IQ=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/sqlite v1.30.1 h1:YFhPVfu2iIgUf9kuA1CR7iiHdcEEsI2i+yjRYHscyxk=
+modernc.org/sqlite v1.30.1/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/common/projectconfig/testdata/go/findconfig/subdir/.keepme b/common/projectconfig/testdata/go/findconfig/subdir/.keepme
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/examples/go/echo/go.mod b/examples/go/echo/go.mod
index 7e6e878c8c..7777ec9a6d 100644
--- a/examples/go/echo/go.mod
+++ b/examples/go/echo/go.mod
@@ -4,7 +4,7 @@ go 1.22.2
 
 replace github.com/TBD54566975/ftl => ../../..
 
-require github.com/TBD54566975/ftl v0.241.2
+require github.com/TBD54566975/ftl v0.248.0
 
 require (
 	connectrpc.com/connect v1.16.1 // indirect
diff --git a/integration/actions.go b/integration/actions.go
index 5dbb734947..b781e30213 100644
--- a/integration/actions.go
+++ b/integration/actions.go
@@ -60,6 +60,16 @@ func CopyModule(module string) Action {
 	)
 }
 
+// SetEnv sets an environment variable for the duration of the test.
+//
+// Note that the FTL controller will already be running.
+func SetEnv(key string, value func(ic TestContext) string) Action {
+	return func(t testing.TB, ic TestContext) {
+		Infof("Setting environment variable %s=%s", key, value(ic))
+		t.Setenv(key, value(ic))
+	}
+}
+
 // Copy a directory from the testdata directory to the working directory.
 func CopyDir(src, dest string) Action {
 	return func(t testing.TB, ic TestContext) {
@@ -114,7 +124,7 @@ func DebugShell() Action {
 // Exec runs a command from the test working directory.
 func Exec(cmd string, args ...string) Action {
 	return func(t testing.TB, ic TestContext) {
-		Infof("Executing: %s %s", cmd, shellquote.Join(args...))
+		Infof("Executing (in %s): %s %s", ic.workDir, cmd, shellquote.Join(args...))
 		err := ftlexec.Command(ic, log.Debug, ic.workDir, cmd, args...).RunBuffered(ic)
 		assert.NoError(t, err)
 	}
diff --git a/integration/harness.go b/integration/harness.go
index 0b7e8b3a81..8b7353f277 100644
--- a/integration/harness.go
+++ b/integration/harness.go
@@ -78,6 +78,9 @@ func run(t *testing.T, ftlConfigPath string, startController bool, actions ...Ac
 		// can't be loaded until the module is copied over, and the config itself
 		// is used by FTL during startup.
 		t.Setenv("FTL_CONFIG", filepath.Join(cwd, "testdata", "go", ftlConfigPath))
+	} else {
+		err = os.WriteFile(filepath.Join(tmpDir, "ftl-project.toml"), []byte{}, 0644)
+		assert.NoError(t, err)
 	}
 
 	// Build FTL binary
@@ -142,6 +145,9 @@ type TestContext struct {
 	Verbs      ftlv1connect.VerbServiceClient
 }
 
+// WorkingDir returns the temporary directory the test is executing in.
+func (i TestContext) WorkingDir() string { return i.workDir }
+
 // AssertWithRetry asserts that the given action passes within the timeout.
 func (i TestContext) AssertWithRetry(t testing.TB, assertion Action) {
 	waitCtx, done := context.WithTimeout(i, integrationTestTimeout)
@@ -166,6 +172,7 @@ func (i TestContext) runAssertionOnce(t testing.TB, assertion Action) (err error
 		switch r := recover().(type) {
 		case TestingError:
 			err = errors.New(string(r))
+			fmt.Println(string(r))
 
 		case nil:
 			return