From 58c79cf38b5a95398879b849437bfdedcd355289 Mon Sep 17 00:00:00 2001 From: James <10496761+jamesgibbons92@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:14:56 +0000 Subject: [PATCH 1/4] --purge arg on remove --- cmd/sst/main.go | 8 ++++++++ cmd/sst/remove.go | 1 + pkg/project/provider/aws.go | 16 ++++++++++++++++ pkg/project/provider/cloudflare.go | 4 ++++ pkg/project/provider/local.go | 4 ++++ pkg/project/provider/provider.go | 14 ++++++++++++++ pkg/project/run.go | 3 +++ pkg/project/stack.go | 1 + 8 files changed, 51 insertions(+) diff --git a/cmd/sst/main.go b/cmd/sst/main.go index 6e5a276b08..fac000af2b 100644 --- a/cmd/sst/main.go +++ b/cmd/sst/main.go @@ -837,6 +837,14 @@ var root = &cli.Command{ Long: "Only run it for the given component.", }, }, + { + Name: "purge", + Type: "bool", + Description: cli.Description{ + Short: "Fully remove the stage state", + Long: "Remove state file and passphrase associated with the stage. Warning: This is irreversible, the state encryption key and all state versions will be unrecoverable.", + }, + }, }, Run: CmdRemove, }, diff --git a/cmd/sst/remove.go b/cmd/sst/remove.go index c7394e0d41..03aa96b599 100644 --- a/cmd/sst/remove.go +++ b/cmd/sst/remove.go @@ -47,6 +47,7 @@ func CmdRemove(c *cli.Cli) error { err = p.Run(c.Context, &project.StackInput{ Command: "remove", Target: target, + Purge: c.Bool("purge"), ServerPort: s.Port, Verbose: c.Bool("verbose"), }) diff --git a/pkg/project/provider/aws.go b/pkg/project/provider/aws.go index 1ee95fc941..ccbc1831f5 100644 --- a/pkg/project/provider/aws.go +++ b/pkg/project/provider/aws.go @@ -691,6 +691,22 @@ func (a *AwsHome) getPassphrase(app string, stage string) (string, error) { return *result.Parameter.Value, nil } +func (a *AwsHome) removePassphrase(app, stage string) error { + ssmClient := ssm.NewFromConfig(a.provider.config) + + _, err := ssmClient.DeleteParameter(context.TODO(), &ssm.DeleteParameterInput{ + Name: aws.String(a.pathForPassphrase(app, stage)), + }) + if err != nil { + var pnf *ssmTypes.ParameterNotFound + if errors.As(err, &pnf) { + return nil + } + return err + } + return nil +} + func (a *AwsHome) setPassphrase(app, stage, passphrase string) error { ssmClient := ssm.NewFromConfig(a.provider.config) diff --git a/pkg/project/provider/cloudflare.go b/pkg/project/provider/cloudflare.go index 2f6cbf8a93..dfb4fb2925 100644 --- a/pkg/project/provider/cloudflare.go +++ b/pkg/project/provider/cloudflare.go @@ -169,6 +169,10 @@ func (c *CloudflareHome) removeData(kind, app, stage string) error { return nil } +func (c *CloudflareHome) removePassphrase(app, stage string) error { + return c.removeData("passphrase", app, stage) +} + // these should go into secrets manager once it's out of beta func (c *CloudflareHome) setPassphrase(app, stage string, passphrase string) error { return c.putData("passphrase", app, stage, bytes.NewReader([]byte(passphrase))) diff --git a/pkg/project/provider/local.go b/pkg/project/provider/local.go index e250b24a44..9f75473b10 100644 --- a/pkg/project/provider/local.go +++ b/pkg/project/provider/local.go @@ -65,6 +65,10 @@ func (l *LocalHome) removeData(key, app, stage string) error { return os.Remove(p) } +func (c *LocalHome) removePassphrase(app, stage string) error { + return c.removeData("passphrase", app, stage) +} + // these should go into secrets manager once it's out of beta func (c *LocalHome) setPassphrase(app, stage string, passphrase string) error { return c.putData("passphrase", app, stage, bytes.NewReader([]byte(passphrase))) diff --git a/pkg/project/provider/provider.go b/pkg/project/provider/provider.go index f2f1a8acb9..d440be5287 100644 --- a/pkg/project/provider/provider.go +++ b/pkg/project/provider/provider.go @@ -29,6 +29,7 @@ type Home interface { getPassphrase(app, stage string) (string, error) listStages(app string) ([]string, error) cleanup(key, app, stage string) error + removePassphrase(app, stage string) error info() (util.KeyValuePairs[string], error) } @@ -168,6 +169,19 @@ func Cleanup(backend Home, app, stage string) error { return nil } +func Purge(backend Home, app, stage string) error { + if err := backend.removeData("secret", app, stage); err != nil { + return err + } + if err := backend.removeData("app", app, stage); err != nil { + return err + } + if err := backend.removePassphrase(app, stage); err != nil { + return err + } + return nil +} + func GetSecrets(backend Home, app, stage string) (map[string]string, error) { if stage == "" { stage = "_fallback" diff --git a/pkg/project/run.go b/pkg/project/run.go index a21c54280d..f7e66ab041 100644 --- a/pkg/project/run.go +++ b/pkg/project/run.go @@ -670,6 +670,9 @@ loop: if input.Command == "remove" && len(complete.Resources) == 0 { provider.Cleanup(p.home, p.app.Name, p.app.Stage) + if input.Purge { + provider.Purge(p.home, p.app.Name, p.app.Stage) + } } log.Info("done running stack command", "resources", len(complete.Resources)) diff --git a/pkg/project/stack.go b/pkg/project/stack.go index e7b2473667..c6fc5d711d 100644 --- a/pkg/project/stack.go +++ b/pkg/project/stack.go @@ -23,6 +23,7 @@ type StackInput struct { Dev bool Verbose bool Continue bool + Purge bool SkipHash string PolicyPath string } From 6535fca888b02c093de15ee26f0b8ba80831aa37 Mon Sep 17 00:00:00 2001 From: James <10496761+jamesgibbons92@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:28:47 +0000 Subject: [PATCH 2/4] Init passphrase on deploy commands only --- pkg/project/provider/provider.go | 15 ++++++++++++++- pkg/project/run.go | 7 ++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/pkg/project/provider/provider.go b/pkg/project/provider/provider.go index d440be5287..812649926b 100644 --- a/pkg/project/provider/provider.go +++ b/pkg/project/provider/provider.go @@ -103,6 +103,18 @@ func Passphrase(backend Home, app, stage string) (string, error) { return "", err } + if passphrase != "" { + cache[app+stage] = passphrase + } + return passphrase, nil +} + +func PassphraseInit(backend Home, app, stage string) (string, error) { + passphrase, err := Passphrase(backend, app, stage) + if err != nil { + return "", err + } + if passphrase == "" { slog.Info("passphrase not found, setting passphrase", "app", app, "stage", stage) passphrase = flag.SST_PASSPHRASE @@ -118,9 +130,10 @@ func Passphrase(backend Home, app, stage string) (string, error) { if err != nil { return "", err } + + passphraseCache[backend][app+stage] = passphrase } - existingPassphrase, ok = cache[app+stage] return passphrase, nil } diff --git a/pkg/project/run.go b/pkg/project/run.go index f7e66ab041..9b71b72b97 100644 --- a/pkg/project/run.go +++ b/pkg/project/run.go @@ -70,7 +70,12 @@ func (p *Project) Run(ctx context.Context, input *StackInput) error { } defer workdir.Cleanup() - passphrase, err := provider.Passphrase(p.home, p.app.Name, p.app.Stage) + var passphrase string + if input.Command == "deploy" || input.Dev { + passphrase, err = provider.PassphraseInit(p.home, p.app.Name, p.app.Stage) + } else { + passphrase, err = provider.Passphrase(p.home, p.app.Name, p.app.Stage) + } if err != nil { return err } From ee9e97ca537475a255b819c67227f885963af9a6 Mon Sep 17 00:00:00 2001 From: James <10496761+jamesgibbons92@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:34:00 +0100 Subject: [PATCH 3/4] use state config object in sst.config.ts --- cmd/sst/main.go | 8 ------- cmd/sst/remove.go | 1 - pkg/project/project.go | 5 +++++ pkg/project/run.go | 2 +- pkg/project/stack.go | 1 - platform/src/config.ts | 49 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/cmd/sst/main.go b/cmd/sst/main.go index fac000af2b..6e5a276b08 100644 --- a/cmd/sst/main.go +++ b/cmd/sst/main.go @@ -837,14 +837,6 @@ var root = &cli.Command{ Long: "Only run it for the given component.", }, }, - { - Name: "purge", - Type: "bool", - Description: cli.Description{ - Short: "Fully remove the stage state", - Long: "Remove state file and passphrase associated with the stage. Warning: This is irreversible, the state encryption key and all state versions will be unrecoverable.", - }, - }, }, Run: CmdRemove, }, diff --git a/cmd/sst/remove.go b/cmd/sst/remove.go index 03aa96b599..c7394e0d41 100644 --- a/cmd/sst/remove.go +++ b/cmd/sst/remove.go @@ -47,7 +47,6 @@ func CmdRemove(c *cli.Cli) error { err = p.Run(c.Context, &project.StackInput{ Command: "remove", Target: target, - Purge: c.Bool("purge"), ServerPort: s.Port, Verbose: c.Bool("verbose"), }) diff --git a/pkg/project/project.go b/pkg/project/project.go index d3685a511f..0bf4ff9844 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -27,6 +27,10 @@ import ( "github.com/sst/sst/v3/pkg/runtime/worker" ) +type AppState struct { + PurgeOnRemove bool `json:"purgeOnRemove"` +} + type App struct { Name string `json:"name"` Stage string `json:"stage"` @@ -36,6 +40,7 @@ type App struct { Version string `json:"version"` Protect bool `json:"protect"` Watch []string `json:"watch"` + State *AppState `json:"state"` // Deprecated: Backend is now Home Backend string `json:"backend"` // Deprecated: RemovalPolicy is now Removal diff --git a/pkg/project/run.go b/pkg/project/run.go index 9b71b72b97..632fcf95da 100644 --- a/pkg/project/run.go +++ b/pkg/project/run.go @@ -675,7 +675,7 @@ loop: if input.Command == "remove" && len(complete.Resources) == 0 { provider.Cleanup(p.home, p.app.Name, p.app.Stage) - if input.Purge { + if p.app.State != nil && p.app.State.PurgeOnRemove { provider.Purge(p.home, p.app.Name, p.app.Stage) } } diff --git a/pkg/project/stack.go b/pkg/project/stack.go index c6fc5d711d..e7b2473667 100644 --- a/pkg/project/stack.go +++ b/pkg/project/stack.go @@ -23,7 +23,6 @@ type StackInput struct { Dev bool Verbose bool Continue bool - Purge bool SkipHash string PolicyPath string } diff --git a/platform/src/config.ts b/platform/src/config.ts index c8297e4326..866d01387a 100644 --- a/platform/src/config.ts +++ b/platform/src/config.ts @@ -286,6 +286,55 @@ export interface App { * The paths are relative to the project root. */ watch?: string[]; + + /** + * Configure how your app's state is managed. + * + * The state keeps track of all your resources, secrets, and the encryption key. By default, + * this data is preserved via versioning even after `sst remove` so you can recover it if needed. + * + * @example + * + * For example, to fully remove the state file and encryption key: + * + * ```ts + * { + * state: { + * purgeOnRemove: input.stage !== "production" + * } + * } + * ``` + */ + state?: { + /** + * If set to `true`, running `sst remove` will fully remove all state associated + * with the stage once all resources have been successfully removed. + * + * This removes the state files, secrets and the encryption passphrase. + * + * :::caution + * This is irreversible. Once the state encryption key is deleted, secrets and all state versions will be + * unrecoverable. + * ::: + * + * :::tip + * Only enable this for ephemeral or development stages. + * ::: + * + * @default false + * + * @example + * + * ```ts + * { + * state: { + * purgeOnRemove: true + * } + * } + * ``` + */ + purgeOnRemove?: boolean; + }; } export interface AppInput { From a6a6342d7868edeaa1b88c82623958b761cc6bec Mon Sep 17 00:00:00 2001 From: James <10496761+jamesgibbons92@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:42:34 +0100 Subject: [PATCH 4/4] Init passphrase when adding a secret Fixes the issue where secret is attempted to be set before a stage is deployed and initialised the passphrase --- pkg/project/provider/provider.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/project/provider/provider.go b/pkg/project/provider/provider.go index 812649926b..38c43af932 100644 --- a/pkg/project/provider/provider.go +++ b/pkg/project/provider/provider.go @@ -183,10 +183,10 @@ func Cleanup(backend Home, app, stage string) error { } func Purge(backend Home, app, stage string) error { - if err := backend.removeData("secret", app, stage); err != nil { + if err := backend.removeData("app", app, stage); err != nil { return err } - if err := backend.removeData("app", app, stage); err != nil { + if err := backend.removeData("secret", app, stage); err != nil { return err } if err := backend.removePassphrase(app, stage); err != nil { @@ -375,7 +375,7 @@ func putData(backend Home, key, app, stage string, encrypt bool, data interface{ return err } if encrypt { - passphrase, err := Passphrase(backend, app, stage) + passphrase, err := PassphraseInit(backend, app, stage) if err != nil { return err }