diff --git a/cmd/flux/build.go b/cmd/flux/build.go
new file mode 100644
index 0000000000000000000000000000000000000000..0c901036ecfe7581a9f8c9fb79138074e9305d1b
--- /dev/null
+++ b/cmd/flux/build.go
@@ -0,0 +1,31 @@
+/*
+Copyright 2021 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"github.com/spf13/cobra"
+)
+
+var buildCmd = &cobra.Command{
+	Use:   "build",
+	Short: "Build a flux resource",
+	Long:  "The build command is used to build flux resources.",
+}
+
+func init() {
+	rootCmd.AddCommand(buildCmd)
+}
diff --git a/cmd/flux/build_kustomization.go b/cmd/flux/build_kustomization.go
new file mode 100644
index 0000000000000000000000000000000000000000..d4d8a8a082499161a6c9602331ef579974ba810d
--- /dev/null
+++ b/cmd/flux/build_kustomization.go
@@ -0,0 +1,80 @@
+/*
+Copyright 2021 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+
+	"github.com/fluxcd/flux2/internal/kustomization"
+	kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2"
+)
+
+var buildKsCmd = &cobra.Command{
+	Use:     "kustomization",
+	Aliases: []string{"ks"},
+	Short:   "Build Kustomization",
+	Long: `The build command queries the Kubernetes API and fetches the specified Flux Kustomization, 
+then it uses the specified files or path to build the overlay to write the resulting multi-doc YAML to stdout.`,
+	Example: `# Create a new overlay.
+flux build kustomization my-app --resources ./path/to/local/manifests`,
+	ValidArgsFunction: resourceNamesCompletionFunc(kustomizev1.GroupVersion.WithKind(kustomizev1.KustomizationKind)),
+	RunE:              buildKsCmdRun,
+}
+
+type buildKsFlags struct {
+	resources string
+}
+
+var buildKsArgs buildKsFlags
+
+func init() {
+	buildKsCmd.Flags().StringVar(&buildKsArgs.resources, "resources", "", "Name of a file containing a file to add to the kustomization file.)")
+	buildCmd.AddCommand(buildKsCmd)
+}
+
+func buildKsCmdRun(cmd *cobra.Command, args []string) error {
+	if len(args) < 1 {
+		return fmt.Errorf("%s name is required", kustomizationType.humanKind)
+	}
+	name := args[0]
+
+	if buildKsArgs.resources == "" {
+		return fmt.Errorf("invalid resource path %q", buildKsArgs.resources)
+	}
+
+	if fs, err := os.Stat(buildKsArgs.resources); err != nil || !fs.IsDir() {
+		return fmt.Errorf("invalid resource path %q", buildKsArgs.resources)
+	}
+
+	builder, err := kustomization.NewBuilder(rootArgs.kubeconfig, rootArgs.kubecontext, rootArgs.namespace, name, buildKsArgs.resources, kustomization.WithTimeout(rootArgs.timeout))
+	if err != nil {
+		return err
+	}
+
+	manifests, err := builder.Build()
+	if err != nil {
+		return err
+	}
+
+	cmd.Print(string(manifests))
+
+	return nil
+
+}
diff --git a/go.mod b/go.mod
index 4bfb0a14ca45b8620dd9266a06ab02919b94c85c..86cb5883422ad24826d693c66f4cce3e43b23aba 100644
--- a/go.mod
+++ b/go.mod
@@ -22,6 +22,7 @@ require (
 	github.com/go-git/go-git/v5 v5.4.2
 	github.com/google/go-cmp v0.5.6
 	github.com/google/go-containerregistry v0.2.0
+	github.com/hashicorp/go-retryablehttp v0.7.0 // indirect
 	github.com/manifoldco/promptui v0.9.0
 	github.com/mattn/go-shellwords v1.0.12
 	github.com/olekukonko/tablewriter v0.0.4
diff --git a/go.sum b/go.sum
index 17b4f8204e1e3ad9d29f0e2921dcc7ff52c6b2af..dd909e609bdcb6ed912477e1984c3420f05f77a1 100644
--- a/go.sum
+++ b/go.sum
@@ -192,6 +192,8 @@ github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5Xh
 github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
 github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
+github.com/drone/envsubst v1.0.3-0.20200804185402-58bc65f69603 h1:PMzPM0wCHDrXlO7TlEq5lqlhGKHEgSnUR4YMSEVKrQ0=
+github.com/drone/envsubst v1.0.3-0.20200804185402-58bc65f69603/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g=
 github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153 h1:yUdfgN0XgIJw7foRItutHYUIhlcKzcSf5vDpdhQAKTc=
@@ -449,8 +451,9 @@ github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iP
 github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
 github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
 github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
-github.com/hashicorp/go-retryablehttp v0.6.8 h1:92lWxgpa+fF3FozM4B3UZtHZMJX8T5XT+TFdCxsPyWs=
 github.com/hashicorp/go-retryablehttp v0.6.8/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
+github.com/hashicorp/go-retryablehttp v0.7.0 h1:eu1EI/mbirUgP5C8hVsTNaGZreBDlYiwC1FZWkvQPQ4=
+github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
 github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
 github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
 github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
diff --git a/internal/kustomization/build.go b/internal/kustomization/build.go
new file mode 100644
index 0000000000000000000000000000000000000000..4b5279a4a433ebc2e5cc137d9802e2dcb75f40b4
--- /dev/null
+++ b/internal/kustomization/build.go
@@ -0,0 +1,228 @@
+/*
+Copyright 2021 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package kustomization
+
+import (
+	"bytes"
+	"context"
+	"encoding/base64"
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/fluxcd/flux2/internal/utils"
+	kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/kustomize/api/konfig"
+	"sigs.k8s.io/kustomize/api/resmap"
+	"sigs.k8s.io/kustomize/api/resource"
+	"sigs.k8s.io/kustomize/kyaml/filesys"
+)
+
+const mask string = "**SOPS**"
+
+var defaultTimeout = 80 * time.Second
+
+// Builder builds yaml manifests
+// It retrieves the kustomization object from the k8s cluster
+// and overlays the manifests with the resources specified in the resourcesPath
+type Builder struct {
+	client        client.WithWatch
+	name          string
+	namespace     string
+	resourcesPath string
+	kustomization *kustomizev1.Kustomization
+	timeout       time.Duration
+}
+
+type BuilderOptionFunc func(b *Builder) error
+
+func WithTimeout(timeout time.Duration) BuilderOptionFunc {
+	return func(b *Builder) error {
+		b.timeout = timeout
+		return nil
+	}
+}
+
+// NewBuilder returns a new Builder
+// to dp : create functional options
+func NewBuilder(kubeconfig string, kubecontext string, namespace, name, resources string, opts ...BuilderOptionFunc) (*Builder, error) {
+	kubeClient, err := utils.KubeClient(kubeconfig, kubecontext)
+	if err != nil {
+		return nil, err
+	}
+
+	b := &Builder{
+		client:        kubeClient,
+		name:          name,
+		namespace:     namespace,
+		resourcesPath: resources,
+	}
+
+	for _, opt := range opts {
+		if err := opt(b); err != nil {
+			return nil, err
+		}
+	}
+
+	if b.timeout == 0 {
+		b.timeout = defaultTimeout
+	}
+
+	return b, nil
+}
+
+func (b *Builder) getKustomization(ctx context.Context) (*kustomizev1.Kustomization, error) {
+	namespacedName := types.NamespacedName{
+		Namespace: b.namespace,
+		Name:      b.name,
+	}
+
+	k := &kustomizev1.Kustomization{}
+	err := b.client.Get(ctx, namespacedName, k)
+	if err != nil {
+		return nil, err
+	}
+
+	return k, nil
+}
+
+// Build builds the yaml manifests from the kustomization object
+// and overlays the manifests with the resources specified in the resourcesPath
+// It expects a kustomization.yaml file in the resourcesPath, and it will
+// generate a kustomization.yaml file if it doesn't exist
+func (b *Builder) Build() ([]byte, error) {
+	m, err := b.build()
+	if err != nil {
+		return nil, err
+	}
+
+	resources, err := m.AsYaml()
+	if err != nil {
+		return nil, fmt.Errorf("kustomize build failed: %w", err)
+	}
+
+	return resources, nil
+}
+
+func (b *Builder) build() (resmap.ResMap, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+	defer cancel()
+
+	// Get the kustomization object
+	k, err := b.getKustomization(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	// generate kustomization.yaml if needed
+	saved, err := b.generate(*k, b.resourcesPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate kustomization.yaml: %w", err)
+	}
+
+	// build the kustomization
+	m, err := b.do(ctx, *k, b.resourcesPath)
+	if err != nil {
+		return nil, err
+	}
+
+	// make sure secrets are masked
+	for _, res := range m.Resources() {
+		err := trimSopsData(res)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	// store the kustomization object
+	b.kustomization = k
+
+	// restore the kustomization.yaml
+	err = restore(saved, b.resourcesPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to restore kustomization.yaml: %w", err)
+	}
+
+	return m, nil
+
+}
+
+func (b *Builder) generate(kustomization kustomizev1.Kustomization, dirPath string) ([]byte, error) {
+	gen := NewGenerator(&kustomizeImpl{kustomization})
+	return gen.WriteFile(dirPath)
+}
+
+func (b *Builder) do(ctx context.Context, kustomization kustomizev1.Kustomization, dirPath string) (resmap.ResMap, error) {
+	fs := filesys.MakeFsOnDisk()
+	m, err := buildKustomization(fs, dirPath)
+	if err != nil {
+		return nil, fmt.Errorf("kustomize build failed: %w", err)
+	}
+
+	for _, res := range m.Resources() {
+		// run variable substitutions
+		if kustomization.Spec.PostBuild != nil {
+			outRes, err := substituteVariables(ctx, b.client, &kustomizeImpl{kustomization}, res)
+			if err != nil {
+				return nil, fmt.Errorf("var substitution failed for '%s': %w", res.GetName(), err)
+			}
+
+			if outRes != nil {
+				_, err = m.Replace(res)
+				if err != nil {
+					return nil, err
+				}
+			}
+		}
+	}
+
+	return m, nil
+}
+
+func trimSopsData(res *resource.Resource) error {
+	// sopsMess is the base64 encoded mask
+	sopsMess := base64.StdEncoding.EncodeToString([]byte(mask))
+
+	if res.GetKind() == "Secret" {
+		dataMap := res.GetDataMap()
+		for k, v := range dataMap {
+			data, err := base64.StdEncoding.DecodeString(v)
+			if err != nil {
+				fmt.Println(fmt.Errorf("failed to decode secret data: %w", err))
+			}
+
+			if bytes.Contains(data, []byte("sops")) {
+				dataMap[k] = sopsMess
+			}
+		}
+		res.SetDataMap(dataMap)
+	}
+
+	return nil
+}
+
+func restore(saved []byte, dirPath string) error {
+	kfile := filepath.Join(dirPath, konfig.DefaultKustomizationFileName())
+	err := os.WriteFile(kfile, saved, 0644)
+	if err != nil {
+		return fmt.Errorf("failed to restore kustomization.yaml: %w", err)
+	}
+	return nil
+}
diff --git a/internal/kustomization/build_test.go b/internal/kustomization/build_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..7834e108c6210a626fa90987fadce5d6c60da003
--- /dev/null
+++ b/internal/kustomization/build_test.go
@@ -0,0 +1,120 @@
+/*
+Copyright 2021 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package kustomization
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"sigs.k8s.io/kustomize/api/resource"
+	"sigs.k8s.io/kustomize/kyaml/yaml"
+)
+
+func TestSanitizeResources(t *testing.T) {
+	testCases := []struct {
+		name     string
+		yamlStr  string
+		expected string
+	}{
+		{
+			name: "secret with sops token",
+			yamlStr: `apiVersion: v1
+kind: Secret
+metadata:
+  name: my-secret
+type: Opaque
+data:
+  token: |
+    ewoJImRhdGEiOiAiRU5DW0FFUzI1Nl9HQ00sZGF0YTpvQmU1UGxQbWZRQ1VVYzRzcUtJbW
+    p3PT0saXY6TUxMRVcxNVFDOWtSZFZWYWdKbnpMQ1NrMHhaR1dJcEFlVGZIenl4VDEwZz0s
+    dGFnOkszR2tCQ0dTK3V0NFRwazZuZGIwQ0E9PSx0eXBlOnN0cl0iLAoJInNvcHMiOiB7Cg
+    kJImttcyI6IG51bGwsCgkJImdjcF9rbXMiOiBudWxsLAoJCSJhenVyZV9rdiI6IG51bGws
+    CgkJImhjX3ZhdWx0IjogbnVsbCwKCQkiYWdlIjogWwoJCQl7CgkJCQkicmVjaXBpZW50Ij
+    ogImFnZTEwbGEyZ2Uwd3R2eDNxcjdkYXRxZjdyczR5bmd4c3pkYWw5MjdmczlydWthbXI4
+    dTJwc2hzdnR6N2NlIiwKCQkJCSJlbmMiOiAiLS0tLS1CRUdJTiBBR0UgRU5DUllQVEVEIE
+    ZJTEUtLS0tLVxuWVdkbExXVnVZM0o1Y0hScGIyNHViM0puTDNZeENpMCtJRmd5TlRVeE9T
+    QTFMMlJwWkhScksxRlNWbVlyZDFWYVxuWTBoeFdGUXpTREJzVDFrM1dqTnRZbVUxUW1saW
+    FESnljWGxOQ25GMVlqZE5PVGhWYlZOdk1HOXJOUzlaVVhad1xuTW5WMGJuUlVNR050ZWpG
+    UGJ6TTRVMlV6V2tzemVWa0tMUzB0SUdKNlVHaHhNVVYzWW1WSlRIbEpTVUpwUlZSWlxuVm
+    pkMFJWUmFkVTh3ZWt4WFRISXJZVXBsWWtOMmFFRUswSS9NQ0V0WFJrK2IvTjJHMUpGM3ZI
+    UVQyNGRTaFdZRFxudytKSVVTQTNhTGYyc3YwenIyTWRVRWRWV0JKb004blQ0RDR4VmJCT1
+    JEKzY2OVcrOW5EZVN3PT1cbi0tLS0tRU5EIEFHRSBFTkNSWVBURUQgRklMRS0tLS0tXG4i
+    CgkJCX0KCQldLAoJCSJsYXN0bW9kaWZpZWQiOiAiMjAyMS0xMS0yNlQxNjozNDo1MVoiLA
+    oJCSJtYWMiOiAiRU5DW0FFUzI1Nl9HQ00sZGF0YTpDT0d6ZjVZQ0hOTlA2ejRKYUVLcmpO
+    M004ZjUrUTF1S1VLVE1Id2ozODgvSUNtTHlpMnNTclRtajdQUCtYN005alRWd2E4d1ZnWV
+    RwTkxpVkp4K0xjeHF2SVhNMFR5bysvQ3UxenJmYW85OGFpQUNQOCtUU0VEaUZRTnRFdXMy
+    M0grZC9YMWhxTXdSSERJM2tRKzZzY2dFR25xWTU3cjNSRFNBM0U4RWhIcjQ9LGl2Okx4aX
+    RWSVltOHNyWlZxRnVlSmg5bG9DbEE0NFkyWjNYQVZZbXhlc01tT2c9LHRhZzpZOHFGRDhV
+    R2xEZndOU3Y3eGxjbjZBPT0sdHlwZTpzdHJdIiwKCQkicGdwIjogbnVsbCwKCQkidW5lbm
+    NyeXB0ZWRfc3VmZml4IjogIl91bmVuY3J5cHRlZCIsCgkJInZlcnNpb24iOiAiMy43LjEi
+    Cgl9Cn0=
+`,
+			expected: `apiVersion: v1
+data:
+  token: KipTT1BTKio=
+kind: Secret
+metadata:
+  name: my-secret
+type: Opaque
+`,
+		},
+		{
+			name: "secret with basic auth",
+			yamlStr: `apiVersion: v1
+kind: Secret
+metadata:
+  name: secret-basic-auth
+type: kubernetes.io/basic-auth
+data:
+  username: admin
+  password: password
+`,
+			expected: `apiVersion: v1
+data:
+  password: password
+  username: admin
+kind: Secret
+metadata:
+  name: secret-basic-auth
+type: kubernetes.io/basic-auth
+`,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			r, err := yaml.Parse(tc.yamlStr)
+			if err != nil {
+				t.Fatalf("unable to parse yaml: %v", err)
+			}
+
+			resource := &resource.Resource{RNode: *r}
+			err = trimSopsData(resource)
+			if err != nil {
+				t.Fatalf("unable to trim sops data: %v", err)
+			}
+
+			sYaml, err := resource.AsYAML()
+			if err != nil {
+				t.Fatalf("unable to convert sanitized resources to yaml: %v", err)
+			}
+			if diff := cmp.Diff(string(sYaml), tc.expected); diff != "" {
+				t.Errorf("unexpected sanitized resources: (-got +want)%v", diff)
+			}
+		})
+	}
+}
diff --git a/internal/kustomization/kustomization.go b/internal/kustomization/kustomization.go
new file mode 100644
index 0000000000000000000000000000000000000000..537d0011b4166c231f404dcabbcc5c04321e7398
--- /dev/null
+++ b/internal/kustomization/kustomization.go
@@ -0,0 +1,84 @@
+/*
+Copyright 2021 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package kustomization
+
+import (
+	kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2"
+	"github.com/fluxcd/pkg/apis/kustomize"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// Kustomize defines the methods to retrieve the kustomization information
+// TO DO @souleb: move this to fluxcd/pkg along with generator and varsub
+type Kustomize interface {
+	client.Object
+	GetTargetNamespace() string
+	GetPatches() []kustomize.Patch
+	GetPatchesStrategicMerge() []apiextensionsv1.JSON
+	GetPatchesJSON6902() []kustomize.JSON6902Patch
+	GetImages() []kustomize.Image
+	GetSubstituteFrom() []SubstituteReference
+	GetSubstitute() map[string]string
+}
+
+// SubstituteReference contains a reference to a resource containing
+// the variables name and value.
+type SubstituteReference struct {
+	Kind string `json:"kind"`
+	Name string `json:"name"`
+}
+
+// TO DO @souleb: this is a temporary hack to get the kustomize object
+// from the kustomize controller.
+// At some point we should remove this and have the kustomize controller implement
+// the Kustomize interface.
+type kustomizeImpl struct {
+	kustomizev1.Kustomization
+}
+
+func (k *kustomizeImpl) GetTargetNamespace() string {
+	return k.Spec.TargetNamespace
+}
+
+func (k *kustomizeImpl) GetPatches() []kustomize.Patch {
+	return k.Spec.Patches
+}
+
+func (k *kustomizeImpl) GetPatchesStrategicMerge() []apiextensionsv1.JSON {
+	return k.Spec.PatchesStrategicMerge
+}
+
+func (k *kustomizeImpl) GetPatchesJSON6902() []kustomize.JSON6902Patch {
+	return k.Spec.PatchesJSON6902
+}
+
+func (k *kustomizeImpl) GetImages() []kustomize.Image {
+	return k.Spec.Images
+}
+
+func (k *kustomizeImpl) GetSubstituteFrom() []SubstituteReference {
+	refs := make([]SubstituteReference, 0, len(k.Spec.PostBuild.SubstituteFrom))
+	for _, s := range k.Spec.PostBuild.SubstituteFrom {
+		refs = append(refs, SubstituteReference(s))
+	}
+	return refs
+}
+
+func (k *kustomizeImpl) GetSubstitute() map[string]string {
+	return k.Spec.PostBuild.Substitute
+}
diff --git a/internal/kustomization/kustomization_generator.go b/internal/kustomization/kustomization_generator.go
new file mode 100644
index 0000000000000000000000000000000000000000..829c3bdfb76178cc02e78a3646c58ad3256331b2
--- /dev/null
+++ b/internal/kustomization/kustomization_generator.go
@@ -0,0 +1,258 @@
+/*
+Copyright 2021 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package kustomization
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+
+	"sigs.k8s.io/kustomize/api/konfig"
+	"sigs.k8s.io/kustomize/api/krusty"
+	"sigs.k8s.io/kustomize/api/provider"
+	"sigs.k8s.io/kustomize/api/resmap"
+	kustypes "sigs.k8s.io/kustomize/api/types"
+	"sigs.k8s.io/kustomize/kyaml/filesys"
+	"sigs.k8s.io/yaml"
+
+	"github.com/fluxcd/pkg/apis/kustomize"
+)
+
+type KustomizeGenerator struct {
+	kustomization Kustomize
+}
+
+func NewGenerator(kustomization Kustomize) *KustomizeGenerator {
+	return &KustomizeGenerator{
+		kustomization: kustomization,
+	}
+}
+
+// WriteFile generates a kustomization.yaml in the given directory if it does not exist.
+// It apply the flux kustomize resources to the kustomization.yaml and then write the
+// updated kustomization.yaml to the directory.
+// It returns the original kustomization.yaml.
+func (kg *KustomizeGenerator) WriteFile(dirPath string) ([]byte, error) {
+	if err := kg.generateKustomization(dirPath); err != nil {
+		return nil, err
+	}
+
+	kfile := filepath.Join(dirPath, konfig.DefaultKustomizationFileName())
+
+	data, err := os.ReadFile(kfile)
+	if err != nil {
+		return nil, err
+	}
+
+	kus := kustypes.Kustomization{
+		TypeMeta: kustypes.TypeMeta{
+			APIVersion: kustypes.KustomizationVersion,
+			Kind:       kustypes.KustomizationKind,
+		},
+	}
+
+	if err := yaml.Unmarshal(data, &kus); err != nil {
+		return nil, err
+	}
+
+	if kg.kustomization.GetTargetNamespace() != "" {
+		kus.Namespace = kg.kustomization.GetTargetNamespace()
+	}
+
+	for _, m := range kg.kustomization.GetPatches() {
+		kus.Patches = append(kus.Patches, kustypes.Patch{
+			Patch:  m.Patch,
+			Target: adaptSelector(&m.Target),
+		})
+	}
+
+	for _, m := range kg.kustomization.GetPatchesStrategicMerge() {
+		kus.PatchesStrategicMerge = append(kus.PatchesStrategicMerge, kustypes.PatchStrategicMerge(m.Raw))
+	}
+
+	for _, m := range kg.kustomization.GetPatchesJSON6902() {
+		patch, err := json.Marshal(m.Patch)
+		if err != nil {
+			return nil, err
+		}
+		kus.PatchesJson6902 = append(kus.PatchesJson6902, kustypes.Patch{
+			Patch:  string(patch),
+			Target: adaptSelector(&m.Target),
+		})
+	}
+
+	for _, image := range kg.kustomization.GetImages() {
+		newImage := kustypes.Image{
+			Name:    image.Name,
+			NewName: image.NewName,
+			NewTag:  image.NewTag,
+		}
+		if exists, index := checkKustomizeImageExists(kus.Images, image.Name); exists {
+			kus.Images[index] = newImage
+		} else {
+			kus.Images = append(kus.Images, newImage)
+		}
+	}
+
+	manifest, err := yaml.Marshal(kus)
+	if err != nil {
+		return nil, err
+	}
+
+	os.WriteFile(kfile, manifest, 0644)
+
+	return data, nil
+}
+
+func checkKustomizeImageExists(images []kustypes.Image, imageName string) (bool, int) {
+	for i, image := range images {
+		if imageName == image.Name {
+			return true, i
+		}
+	}
+
+	return false, -1
+}
+
+func (kg *KustomizeGenerator) generateKustomization(dirPath string) error {
+	fs := filesys.MakeFsOnDisk()
+
+	// Determine if there already is a Kustomization file at the root,
+	// as this means we do not have to generate one.
+	for _, kfilename := range konfig.RecognizedKustomizationFileNames() {
+		if kpath := filepath.Join(dirPath, kfilename); fs.Exists(kpath) && !fs.IsDir(kpath) {
+			return nil
+		}
+	}
+
+	scan := func(base string) ([]string, error) {
+		var paths []string
+		pvd := provider.NewDefaultDepProvider()
+		rf := pvd.GetResourceFactory()
+		err := fs.Walk(base, func(path string, info os.FileInfo, err error) error {
+			if err != nil {
+				return err
+			}
+			if path == base {
+				return nil
+			}
+			if info.IsDir() {
+				// If a sub-directory contains an existing kustomization file add the
+				// directory as a resource and do not decend into it.
+				for _, kfilename := range konfig.RecognizedKustomizationFileNames() {
+					if kpath := filepath.Join(path, kfilename); fs.Exists(kpath) && !fs.IsDir(kpath) {
+						paths = append(paths, path)
+						return filepath.SkipDir
+					}
+				}
+				return nil
+			}
+
+			extension := filepath.Ext(path)
+			if extension != ".yaml" && extension != ".yml" {
+				return nil
+			}
+
+			fContents, err := fs.ReadFile(path)
+			if err != nil {
+				return err
+			}
+
+			if _, err := rf.SliceFromBytes(fContents); err != nil {
+				return fmt.Errorf("failed to decode Kubernetes YAML from %s: %w", path, err)
+			}
+			paths = append(paths, path)
+			return nil
+		})
+		return paths, err
+	}
+
+	abs, err := filepath.Abs(dirPath)
+	if err != nil {
+		return err
+	}
+
+	files, err := scan(abs)
+	if err != nil {
+		return err
+	}
+
+	kfile := filepath.Join(dirPath, konfig.DefaultKustomizationFileName())
+	f, err := fs.Create(kfile)
+	if err != nil {
+		return err
+	}
+	f.Close()
+
+	kus := kustypes.Kustomization{
+		TypeMeta: kustypes.TypeMeta{
+			APIVersion: kustypes.KustomizationVersion,
+			Kind:       kustypes.KustomizationKind,
+		},
+	}
+
+	var resources []string
+	for _, file := range files {
+		resources = append(resources, strings.Replace(file, abs, ".", 1))
+	}
+
+	kus.Resources = resources
+	kd, err := yaml.Marshal(kus)
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(kfile, kd, os.ModePerm)
+}
+
+func adaptSelector(selector *kustomize.Selector) (output *kustypes.Selector) {
+	if selector != nil {
+		output = &kustypes.Selector{}
+		output.Gvk.Group = selector.Group
+		output.Gvk.Kind = selector.Kind
+		output.Gvk.Version = selector.Version
+		output.Name = selector.Name
+		output.Namespace = selector.Namespace
+		output.LabelSelector = selector.LabelSelector
+		output.AnnotationSelector = selector.AnnotationSelector
+	}
+	return
+}
+
+// TODO: remove mutex when kustomize fixes the concurrent map read/write panic
+var kustomizeBuildMutex sync.Mutex
+
+// buildKustomization wraps krusty.MakeKustomizer with the following settings:
+// - load files from outside the kustomization.yaml root
+// - disable plugins except for the builtin ones
+func buildKustomization(fs filesys.FileSystem, dirPath string) (resmap.ResMap, error) {
+	// temporary workaround for concurrent map read and map write bug
+	// https://github.com/kubernetes-sigs/kustomize/issues/3659
+	kustomizeBuildMutex.Lock()
+	defer kustomizeBuildMutex.Unlock()
+
+	buildOptions := &krusty.Options{
+		LoadRestrictions: kustypes.LoadRestrictionsNone,
+		PluginConfig:     kustypes.DisabledPluginConfig(),
+	}
+
+	k := krusty.MakeKustomizer(buildOptions)
+	return k.Run(fs, dirPath)
+}
diff --git a/internal/kustomization/kustomization_varsub.go b/internal/kustomization/kustomization_varsub.go
new file mode 100644
index 0000000000000000000000000000000000000000..5a893eb9ebd19634fc871a4c54da58b3324db529
--- /dev/null
+++ b/internal/kustomization/kustomization_varsub.go
@@ -0,0 +1,119 @@
+/*
+Copyright 2021 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package kustomization
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+	"strings"
+
+	"github.com/drone/envsubst"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/kustomize/api/resource"
+	"sigs.k8s.io/yaml"
+)
+
+const (
+	// varsubRegex is the regular expression used to validate
+	// the var names before substitution
+	varsubRegex   = "^[_[:alpha:]][_[:alpha:][:digit:]]*$"
+	DisabledValue = "disabled"
+)
+
+// substituteVariables replaces the vars with their values in the specified resource.
+// If a resource is labeled or annotated with
+// 'kustomize.toolkit.fluxcd.io/substitute: disabled' the substitution is skipped.
+func substituteVariables(
+	ctx context.Context,
+	kubeClient client.Client,
+	kustomization Kustomize,
+	res *resource.Resource) (*resource.Resource, error) {
+	resData, err := res.AsYAML()
+	if err != nil {
+		return nil, err
+	}
+
+	key := fmt.Sprintf("%s/substitute", kustomization.GetObjectKind().GroupVersionKind().Group)
+
+	if res.GetLabels()[key] == DisabledValue || res.GetAnnotations()[key] == DisabledValue {
+		return nil, nil
+	}
+
+	vars := make(map[string]string)
+
+	// load vars from ConfigMaps and Secrets data keys
+	for _, reference := range kustomization.GetSubstituteFrom() {
+		namespacedName := types.NamespacedName{Namespace: kustomization.GetNamespace(), Name: reference.Name}
+		switch reference.Kind {
+		case "ConfigMap":
+			resource := &corev1.ConfigMap{}
+			if err := kubeClient.Get(ctx, namespacedName, resource); err != nil {
+				return nil, fmt.Errorf("substitute from 'ConfigMap/%s' error: %w", reference.Name, err)
+			}
+			for k, v := range resource.Data {
+				vars[k] = strings.Replace(v, "\n", "", -1)
+			}
+		case "Secret":
+			resource := &corev1.Secret{}
+			if err := kubeClient.Get(ctx, namespacedName, resource); err != nil {
+				return nil, fmt.Errorf("substitute from 'Secret/%s' error: %w", reference.Name, err)
+			}
+			for k, v := range resource.Data {
+				vars[k] = strings.Replace(string(v), "\n", "", -1)
+			}
+		}
+	}
+
+	// load in-line vars (overrides the ones from resources)
+	if kustomization.GetSubstitute() != nil {
+		for k, v := range kustomization.GetSubstitute() {
+			vars[k] = strings.Replace(v, "\n", "", -1)
+		}
+	}
+
+	// run bash variable substitutions
+	if len(vars) > 0 {
+		r, _ := regexp.Compile(varsubRegex)
+		for v := range vars {
+			if !r.MatchString(v) {
+				return nil, fmt.Errorf("'%s' var name is invalid, must match '%s'", v, varsubRegex)
+			}
+		}
+
+		output, err := envsubst.Eval(string(resData), func(s string) string {
+			return vars[s]
+		})
+		if err != nil {
+			return nil, fmt.Errorf("variable substitution failed: %w", err)
+		}
+
+		jsonData, err := yaml.YAMLToJSON([]byte(output))
+		if err != nil {
+			return nil, fmt.Errorf("YAMLToJSON: %w", err)
+		}
+
+		err = res.UnmarshalJSON(jsonData)
+		if err != nil {
+			return nil, fmt.Errorf("UnmarshalJSON: %w", err)
+		}
+	}
+
+	return res, nil
+}