Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8270fce
basic single command to deploy everything
matlin Oct 21, 2025
4895f05
Add remote flag to status
matlin Oct 23, 2025
dcb990c
Add project health spinner to deploy cmd
matlin Oct 24, 2025
02e7aad
feat: add dry-run support to config and functions deployments
matlin Oct 26, 2025
4c2492c
Use linked naming instead of remote for status cmd
matlin Oct 28, 2025
adbda2e
Use seperate dry run funcs for config updating
matlin Oct 28, 2025
e0e0323
simplify config dryrun logic
matlin Oct 28, 2025
b9b3785
use filter for function dry run instead of new param
matlin Oct 29, 2025
afe2ea7
Use filter approach instead of dry run var
matlin Oct 29, 2025
83eb7ae
Simplify deployment component selection
matlin Oct 29, 2025
5a98731
remove 'only' flag
matlin Oct 31, 2025
0584ed7
use quick start group
matlin Nov 7, 2025
e7ab596
address comments
matlin Nov 19, 2025
0bb83b7
Merge branch 'develop' into feat-single-deploy-cmd
sweatybridge Nov 26, 2025
6608f1f
chore: refactor deploy implementation to internal package
sweatybridge Nov 26, 2025
b6a3e01
chore: add missing package
sweatybridge Nov 26, 2025
5bf01c9
Merge branch 'develop' into feat-single-deploy-cmd
sweatybridge Nov 27, 2025
937ab4b
Merge branch 'develop' into feat-single-deploy-cmd
sweatybridge Dec 1, 2025
297ee98
Revert "Use linked naming instead of remote for status cmd"
sweatybridge Dec 1, 2025
3b40392
Revert "Add remote flag to status"
sweatybridge Dec 1, 2025
6e85645
chore: remove unnecessary log
sweatybridge Dec 1, 2025
a318fe3
fix: dry run function deploy with pruning
sweatybridge Dec 1, 2025
9c100ac
chore: prompt before dry run config update
sweatybridge Dec 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ var (
Use: "push",
Short: "Pushes local config.toml to the linked project",
RunE: func(cmd *cobra.Command, args []string) error {
return push.Run(cmd.Context(), flags.ProjectRef, afero.NewOsFs())
return push.Run(cmd.Context(), flags.ProjectRef, dryRun, afero.NewOsFs())
},
}
)

func init() {
configCmd.PersistentFlags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
configPushCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print operations that would be performed without executing them.")
configCmd.AddCommand(configPushCmd)
rootCmd.AddCommand(configCmd)
}
50 changes: 50 additions & 0 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package cmd

import (
"os"
"os/signal"

"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/supabase/cli/internal/deploy"
"github.com/supabase/cli/internal/utils/flags"
)

var (
deployCmd = &cobra.Command{
GroupID: groupQuickStart,
Use: "deploy",
Short: "Push all local changes to a Supabase project",
Long: `Deploy local changes to a remote Supabase project.

By default, this command will:
- Push database migrations (supabase db push)
- Deploy edge functions (supabase functions deploy)

You can optionally include config changes with --include-config.
Use individual flags to customize what gets deployed.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
fsys := afero.NewOsFs()
return deploy.Run(ctx, dryRun, fsys)
},
Example: ` supabase deploy
supabase deploy --include-config
supabase deploy --include-db --include-functions
supabase deploy --dry-run`,
}
)

func init() {
cmdFlags := deployCmd.Flags()
cmdFlags.BoolVar(&dryRun, "dry-run", false, "Print operations that would be performed without executing them")
cmdFlags.String("db-url", "", "Deploys to the database specified by the connection string (must be percent-encoded)")
cmdFlags.Bool("linked", true, "Deploys to the linked project")
cmdFlags.Bool("local", false, "Deploys to the local database")
deployCmd.MarkFlagsMutuallyExclusive("db-url", "linked", "local")
cmdFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database")
cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", cmdFlags.Lookup("password")))
cmdFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project")
rootCmd.AddCommand(deployCmd)
}
3 changes: 2 additions & 1 deletion cmd/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ var (
} else if maxJobs > 1 {
return errors.New("--jobs must be used together with --use-api")
}
return deploy.Run(cmd.Context(), args, useDocker, noVerifyJWT, importMapPath, maxJobs, prune, afero.NewOsFs())
return deploy.Run(cmd.Context(), args, useDocker, noVerifyJWT, importMapPath, maxJobs, prune, dryRun, afero.NewOsFs())
},
}

Expand Down Expand Up @@ -145,6 +145,7 @@ func init() {
deployFlags.UintVarP(&maxJobs, "jobs", "j", 1, "Maximum number of parallel jobs.")
deployFlags.BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")
deployFlags.BoolVar(&prune, "prune", false, "Delete Functions that exist in Supabase project but not locally.")
deployFlags.BoolVar(&dryRun, "dry-run", false, "Print operations that would be performed without executing them.")
deployFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
deployFlags.StringVar(&importMapPath, "import-map", "", "Path to import map file.")
functionsServeCmd.Flags().BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")
Expand Down
27 changes: 15 additions & 12 deletions internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func Run(ctx context.Context, starter StarterTemplate, fsys afero.Fs, options ..
policy.Reset()
if err := backoff.RetryNotify(func() error {
fmt.Fprintln(os.Stderr, "Checking project health...")
return checkProjectHealth(ctx)
return CheckProjectHealth(ctx, flags.ProjectRef, api.Db)
}, policy, utils.NewErrorCallback()); err != nil {
return err
}
Expand Down Expand Up @@ -154,23 +154,26 @@ func suggestAppStart(cwd, command string) string {
return suggestion
}

func checkProjectHealth(ctx context.Context) error {
params := api.V1GetServicesHealthParams{
Services: []api.V1GetServicesHealthParamsServices{api.Db},
}
resp, err := utils.GetSupabase().V1GetServicesHealthWithResponse(ctx, flags.ProjectRef, &params)
func CheckProjectHealth(ctx context.Context, projectRef string, services ...api.V1GetServicesHealthParamsServices) error {
params := api.V1GetServicesHealthParams{Services: services}
resp, err := utils.GetSupabase().V1GetServicesHealthWithResponse(ctx, projectRef, &params)
if err != nil {
return err
}
if resp.JSON200 == nil {
return errors.Errorf("Error status %d: %s", resp.StatusCode(), resp.Body)
return errors.Errorf("failed to check health: %w", err)
} else if resp.JSON200 == nil {
return errors.Errorf("unexpected health check status %d: %s", resp.StatusCode(), string(resp.Body))
}
var allErrors []error
for _, service := range *resp.JSON200 {
if !service.Healthy {
return errors.Errorf("Service not healthy: %s (%s)", service.Name, service.Status)
msg := string(service.Status)
if service.Error != nil {
msg = *service.Error
}
err := errors.Errorf("%s service not healthy: %s", service.Name, msg)
allErrors = append(allErrors, err)
}
}
return nil
return errors.Join(allErrors...)
}

const (
Expand Down
15 changes: 10 additions & 5 deletions internal/config/push/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/supabase/cli/pkg/config"
)

func Run(ctx context.Context, ref string, fsys afero.Fs) error {
func Run(ctx context.Context, ref string, dryRun bool, fsys afero.Fs) error {
if err := flags.LoadConfig(fsys); err != nil {
return err
}
Expand All @@ -26,18 +26,23 @@ func Run(ctx context.Context, ref string, fsys afero.Fs) error {
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "Pushing config to project:", remote.ProjectId)
fmt.Fprintln(os.Stderr, "Checking config for project:", remote.ProjectId)
console := utils.NewConsole()
keep := func(name string) bool {
title := fmt.Sprintf("Do you want to push %s config to remote?", name)
if item, exists := cost[name]; exists {
title = fmt.Sprintf("Enabling %s will cost you %s. Keep it enabled?", item.Name, item.Price)
}
shouldPush, err := console.PromptYesNo(ctx, title, true)
if err != nil {
if shouldPush, err := console.PromptYesNo(ctx, title, true); err != nil {
fmt.Fprintln(os.Stderr, err)
} else if !shouldPush {
return false
}
return shouldPush
if dryRun {
fmt.Fprintln(os.Stderr, "Would update config:", name)
return false
}
return true
}
return client.UpdateRemoteConfig(ctx, remote, keep)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/config/push/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestPushConfig(t *testing.T) {
fsys := afero.NewMemMapFs()
require.NoError(t, utils.WriteFile(utils.ConfigPath, []byte("malformed"), fsys))
// Run test
err := Run(context.Background(), "", fsys)
err := Run(context.Background(), "", false, fsys)
// Check error
assert.ErrorContains(t, err, "toml: expected = after a key, but the document ends there")
})
Expand All @@ -39,7 +39,7 @@ func TestPushConfig(t *testing.T) {
Get("/v1/projects/" + project + "/billing/addons").
Reply(http.StatusServiceUnavailable)
// Run test
err := Run(context.Background(), project, fsys)
err := Run(context.Background(), project, false, fsys)
// Check error
assert.ErrorContains(t, err, "unexpected list addons status 503:")
})
Expand Down
94 changes: 94 additions & 0 deletions internal/deploy/deploy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package deploy

import (
"context"
"fmt"
"os"

"github.com/go-errors/errors"
"github.com/spf13/afero"
"github.com/supabase/cli/internal/bootstrap"
configPush "github.com/supabase/cli/internal/config/push"
"github.com/supabase/cli/internal/db/push"
funcDeploy "github.com/supabase/cli/internal/functions/deploy"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/flags"
"github.com/supabase/cli/pkg/api"
"github.com/supabase/cli/pkg/function"
)

func Run(ctx context.Context, dryRun bool, fsys afero.Fs) error {
fmt.Fprintln(os.Stderr, "Deploying to project:", flags.ProjectRef)
services := []api.V1GetServicesHealthParamsServices{
api.Auth,
// Not checking Realtime for now as it can be flaky
// api.Realtime,
api.Rest,
api.Storage,
api.Db,
}
if err := bootstrap.CheckProjectHealth(ctx, flags.ProjectRef, services...); err != nil {
return err
}

var deployErrors []error

// Maybe deploy database migrations
fmt.Fprintln(os.Stderr, utils.Aqua(">>>"), "Deploying database migrations...")
if err := push.Run(ctx, dryRun, false, false, false, flags.DbConfig, fsys); err != nil {
deployErrors = append(deployErrors, errors.Errorf("db push failed: %w", err))
}
fmt.Fprintln(os.Stderr)

// Maybe deploy edge functions
fmt.Fprintln(os.Stderr, utils.Aqua(">>>"), "Deploying edge functions...")
if err := funcDeploy.Run(ctx, []string{}, true, nil, "", 1, false, dryRun, fsys); err != nil && !errors.Is(err, function.ErrNoDeploy) {
deployErrors = append(deployErrors, errors.Errorf("functions deploy failed: %w", err))
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:")+" Functions deployment failed:", err)
} else if errors.Is(err, function.ErrNoDeploy) {
fmt.Fprintln(os.Stderr, utils.Yellow("⏭ ")+"No functions to deploy")
} else {
// print error just in case
fmt.Fprintln(os.Stderr, err)
if dryRun {
fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" Functions dry run complete")
} else {
fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" Functions deployed successfully")
}
}
fmt.Fprintln(os.Stderr)

// Maybe deploy config
fmt.Fprintln(os.Stderr, utils.Aqua(">>>"), "Deploying config...")
if err := configPush.Run(ctx, flags.ProjectRef, dryRun, fsys); err != nil {
deployErrors = append(deployErrors, errors.Errorf("config push failed: %w", err))
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:")+" Config deployment failed:", err)
} else {
if dryRun {
fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" Config dry run complete")
} else {
fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" Config deployed successfully")
}
}
fmt.Fprintln(os.Stderr)

// Summary
if len(deployErrors) > 0 {
if dryRun {
fmt.Fprintln(os.Stderr, utils.Yellow("Dry run completed with warnings:"))
} else {
fmt.Fprintln(os.Stderr, utils.Yellow("Deploy completed with warnings:"))
}
for _, err := range deployErrors {
fmt.Fprintln(os.Stderr, " •", err)
}
return nil // Don't fail the command for non-critical errors
}

if dryRun {
fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" "+utils.Bold("Dry run completed successfully!"))
} else {
fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" "+utils.Bold("Deployment completed successfully!"))
}
return nil
}
36 changes: 28 additions & 8 deletions internal/functions/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
"github.com/supabase/cli/pkg/function"
)

func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool, importMapPath string, maxJobs uint, prune bool, fsys afero.Fs) error {
func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool, importMapPath string, maxJobs uint, prune, dryRun bool, fsys afero.Fs) error {
// Load function config and project id
if err := flags.LoadConfig(fsys); err != nil {
return err
Expand Down Expand Up @@ -51,7 +51,7 @@ func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool,
if err != nil {
return err
}
// Deploy new and updated functions
// Setup API with optional bundler
opt := function.WithMaxJobs(maxJobs)
if useDocker {
if utils.IsDockerRunning(ctx) {
Expand All @@ -61,19 +61,33 @@ func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool,
}
}
api := function.NewEdgeRuntimeAPI(flags.ProjectRef, *utils.GetSupabase(), opt)
if err := api.Deploy(ctx, functionConfig, afero.NewIOFS(fsys)); errors.Is(err, function.ErrNoDeploy) {
// Deploy new and updated functions
var keep, drop []func(string) bool
if dryRun {
keep = append(keep, func(name string) bool {
fmt.Fprintln(os.Stderr, "Would deploy:", name)
return false
})
drop = append(keep, func(name string) bool {
fmt.Fprintln(os.Stderr, "Would delete:", name)
return false
})
}
if err := api.Deploy(ctx, functionConfig, afero.NewIOFS(fsys), keep...); errors.Is(err, function.ErrNoDeploy) {
fmt.Fprintln(os.Stderr, err)
return nil
} else if err != nil {
return err
}
fmt.Printf("Deployed Functions on project %s: %s\n", utils.Aqua(flags.ProjectRef), strings.Join(slugs, ", "))
url := fmt.Sprintf("%s/project/%v/functions", utils.GetSupabaseDashboardURL(), flags.ProjectRef)
fmt.Println("You can inspect your deployment in the Dashboard: " + url)
if !dryRun {
fmt.Printf("Deployed Functions on project %s: %s\n", utils.Aqua(flags.ProjectRef), strings.Join(slugs, ", "))
url := fmt.Sprintf("%s/project/%v/functions", utils.GetSupabaseDashboardURL(), flags.ProjectRef)
fmt.Println("You can inspect your deployment in the Dashboard: " + url)
}
if !prune {
return nil
}
return pruneFunctions(ctx, functionConfig)
return pruneFunctions(ctx, functionConfig, drop...)
}

func GetFunctionSlugs(fsys afero.Fs) (slugs []string, err error) {
Expand Down Expand Up @@ -163,7 +177,7 @@ func GetFunctionConfig(slugs []string, importMapPath string, noVerifyJWT *bool,
}

// pruneFunctions deletes functions that exist remotely but not locally
func pruneFunctions(ctx context.Context, functionConfig config.FunctionConfig) error {
func pruneFunctions(ctx context.Context, functionConfig config.FunctionConfig, filter ...func(string) bool) error {
resp, err := utils.GetSupabase().V1ListAllFunctionsWithResponse(ctx, flags.ProjectRef)
if err != nil {
return errors.Errorf("failed to list functions: %w", err)
Expand Down Expand Up @@ -191,7 +205,13 @@ func pruneFunctions(ctx context.Context, functionConfig config.FunctionConfig) e
} else if !shouldDelete {
return errors.New(context.Canceled)
}
OUTER:
for _, slug := range toDelete {
for _, keep := range filter {
if !keep(slug) {
continue OUTER
}
}
fmt.Fprintln(os.Stderr, "Deleting Function:", slug)
if err := delete.Undeploy(ctx, flags.ProjectRef, slug); errors.Is(err, delete.ErrNoDelete) {
fmt.Fprintln(utils.GetDebugLogger(), err)
Expand Down
Loading