diff --git a/cmd/flux/diff.go b/cmd/flux/diff.go
index d4ee4804e86947b1a1f907bbf8f3af03d7c39566..55ebf4bb2a692d125bfe3da37b0c7d87aed06376 100644
--- a/cmd/flux/diff.go
+++ b/cmd/flux/diff.go
@@ -23,7 +23,7 @@ import (
 var diffCmd = &cobra.Command{
 	Use:   "diff",
 	Short: "Diff a flux resource",
-	Long:  "The diff command is used to do a server-side dry-run on flux resources, then output the diff.",
+	Long:  "The diff command is used to do a server-side dry-run on flux resources, then prints the diff.",
 }
 
 func init() {
diff --git a/cmd/flux/diff_kustomization.go b/cmd/flux/diff_kustomization.go
index e40f72c93d53c1fb65524770538a5e656a6050c2..e23c6643c8927635986a72fbcef7b0cfae8b7ab4 100644
--- a/cmd/flux/diff_kustomization.go
+++ b/cmd/flux/diff_kustomization.go
@@ -31,8 +31,9 @@ var diffKsCmd = &cobra.Command{
 	Use:     "kustomization",
 	Aliases: []string{"ks"},
 	Short:   "Diff Kustomization",
-	Long:    `The diff command does a build, then it performs a server-side dry-run and output the diff.`,
-	Example: `# Preview changes local changes as they were applied on the cluster
+	Long: `The diff command does a build, then it performs a server-side dry-run and prints the diff.
+Exit status: 0 No differences were found. 1 Differences were found. >1 diff failed with an error.`,
+	Example: `# Preview local changes as they were applied on the cluster
 flux diff kustomization my-app --path ./path/to/local/manifests`,
 	ValidArgsFunction: resourceNamesCompletionFunc(kustomizev1.GroupVersion.WithKind(kustomizev1.KustomizationKind)),
 	RunE:              diffKsCmdRun,
@@ -56,16 +57,16 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error {
 	name := args[0]
 
 	if diffKsArgs.path == "" {
-		return fmt.Errorf("invalid resource path %q", diffKsArgs.path)
+		return &RequestError{StatusCode: 2, Err: fmt.Errorf("invalid resource path %q", diffKsArgs.path)}
 	}
 
 	if fs, err := os.Stat(diffKsArgs.path); err != nil || !fs.IsDir() {
-		return fmt.Errorf("invalid resource path %q", diffKsArgs.path)
+		return &RequestError{StatusCode: 2, Err: fmt.Errorf("invalid resource path %q", diffKsArgs.path)}
 	}
 
 	builder, err := build.NewBuilder(kubeconfigArgs, name, diffKsArgs.path, build.WithTimeout(rootArgs.timeout))
 	if err != nil {
-		return err
+		return &RequestError{StatusCode: 2, Err: err}
 	}
 
 	// create a signal channel
@@ -74,13 +75,18 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error {
 
 	errChan := make(chan error)
 	go func() {
-		output, err := builder.Diff()
+		output, hasChanged, err := builder.Diff()
 		if err != nil {
-			errChan <- err
+			errChan <- &RequestError{StatusCode: 2, Err: err}
 		}
 
 		cmd.Print(output)
-		errChan <- nil
+
+		if hasChanged {
+			errChan <- &RequestError{StatusCode: 1, Err: fmt.Errorf("identified at least one change, exiting with non-zero exit code")}
+		} else {
+			errChan <- nil
+		}
 	}()
 
 	select {
diff --git a/cmd/flux/main.go b/cmd/flux/main.go
index d61bcda9d163ae5dc6095ca6a588fc959d4e4519..5fb1a163ea5baf1b92cc7625b33d54dfb8a0d9a2 100644
--- a/cmd/flux/main.go
+++ b/cmd/flux/main.go
@@ -105,6 +105,16 @@ type rootFlags struct {
 	defaults     install.Options
 }
 
+// RequestError is a custom error type that wraps an error returned by the flux api.
+type RequestError struct {
+	StatusCode int
+	Err        error
+}
+
+func (r *RequestError) Error() string {
+	return r.Err.Error()
+}
+
 var rootArgs = NewRootFlags()
 var kubeconfigArgs = genericclioptions.NewConfigFlags(false)
 
@@ -144,6 +154,11 @@ func main() {
 	log.SetFlags(0)
 	if err := rootCmd.Execute(); err != nil {
 		logger.Failuref("%v", err)
+
+		if err, ok := err.(*RequestError); ok {
+			os.Exit(err.StatusCode)
+		}
+
 		os.Exit(1)
 	}
 }
diff --git a/cmd/flux/main_test.go b/cmd/flux/main_test.go
index 26b4e99e1332e73e974664795c996998e06270cb..c41eb3e6813718bdee79b5704a454fdf78cc42fc 100644
--- a/cmd/flux/main_test.go
+++ b/cmd/flux/main_test.go
@@ -325,6 +325,12 @@ type cmdTestCase struct {
 
 func (cmd *cmdTestCase) runTestCmd(t *testing.T) {
 	actual, testErr := executeCommand(cmd.args)
+
+	// If the cmd error is a change, discard it
+	if isChangeError(testErr) {
+		testErr = nil
+	}
+
 	if assertErr := cmd.assert(actual, testErr); assertErr != nil {
 		t.Error(assertErr)
 	}
@@ -366,3 +372,12 @@ func resetCmdArgs() {
 	getArgs = GetFlags{}
 	secretGitArgs = NewSecretGitFlags()
 }
+
+func isChangeError(err error) bool {
+	if reqErr, ok := err.(*RequestError); ok {
+		if strings.Contains(err.Error(), "identified at least one change, exiting with non-zero exit code") && reqErr.StatusCode == 1 {
+			return true
+		}
+	}
+	return false
+}
diff --git a/internal/build/diff.go b/internal/build/diff.go
index 43b85aae83139fe73114cc7414418d8dd2e14350..13a8270aa306a0ac863560a471f6f517930c71e8 100644
--- a/internal/build/diff.go
+++ b/internal/build/diff.go
@@ -51,28 +51,29 @@ func (b *Builder) Manager() (*ssa.ResourceManager, error) {
 	return ssa.NewResourceManager(b.client, statusPoller, owner), nil
 }
 
-func (b *Builder) Diff() (string, error) {
+func (b *Builder) Diff() (string, bool, error) {
 	output := strings.Builder{}
+	createdOrDrifted := false
 	res, err := b.Build()
 	if err != nil {
-		return "", err
+		return "", createdOrDrifted, err
 	}
 	// convert the build result into Kubernetes unstructured objects
 	objects, err := ssa.ReadObjects(bytes.NewReader(res))
 	if err != nil {
-		return "", err
+		return "", createdOrDrifted, err
 	}
 
 	resourceManager, err := b.Manager()
 	if err != nil {
-		return "", err
+		return "", createdOrDrifted, err
 	}
 
 	ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
 	defer cancel()
 
 	if err := ssa.SetNativeKindsDefaults(objects); err != nil {
-		return "", err
+		return "", createdOrDrifted, err
 	}
 
 	// create an inventory of objects to be reconciled
@@ -101,20 +102,23 @@ func (b *Builder) Diff() (string, error) {
 
 		if change.Action == string(ssa.CreatedAction) {
 			output.WriteString(writeString(fmt.Sprintf("â–º %s created\n", change.Subject), bunt.Green))
+			createdOrDrifted = true
 		}
 
 		if change.Action == string(ssa.ConfiguredAction) {
 			output.WriteString(writeString(fmt.Sprintf("â–º %s drifted\n", change.Subject), bunt.WhiteSmoke))
 			liveFile, mergedFile, tmpDir, err := writeYamls(liveObject, mergedObject)
 			if err != nil {
-				return "", err
+				return "", createdOrDrifted, err
 			}
 			defer cleanupDir(tmpDir)
 
 			err = diff(liveFile, mergedFile, &output)
 			if err != nil {
-				return "", err
+				return "", createdOrDrifted, err
 			}
+
+			createdOrDrifted = true
 		}
 
 		addObjectsToInventory(newInventory, change)
@@ -125,7 +129,7 @@ func (b *Builder) Diff() (string, error) {
 		if oldStatus.Inventory != nil {
 			diffObjects, err := diffInventory(oldStatus.Inventory, newInventory)
 			if err != nil {
-				return "", err
+				return "", createdOrDrifted, err
 			}
 			for _, object := range diffObjects {
 				output.WriteString(writeString(fmt.Sprintf("â–º %s deleted\n", ssa.FmtUnstructured(object)), bunt.OrangeRed))
@@ -133,7 +137,7 @@ func (b *Builder) Diff() (string, error) {
 		}
 	}
 
-	return output.String(), nil
+	return output.String(), createdOrDrifted, nil
 }
 
 func writeYamls(liveObject, mergedObject *unstructured.Unstructured) (string, string, string, error) {