installer: orginize bootstrapper, improve service IP handling
diff --git a/core/installer/Makefile b/core/installer/Makefile
index 9c14dd1..4d76e6b 100644
--- a/core/installer/Makefile
+++ b/core/installer/Makefile
@@ -11,7 +11,7 @@
 	go build -o pcloud cmd/*.go
 
 bootstrap:
-	./pcloud --kubeconfig=../../priv/kubeconfig bootstrap --charts-dir=../../charts --admin-pub-key=/Users/lekva/.ssh/id_rsa.pub --soft-serve-ip=192.168.0.211 --storage-dir=/pcloud-storage/longhorn
+	./pcloud --kubeconfig=../../scripts/hetzner/kubeconfig bootstrap --env-name=dodo --charts-dir=../../charts --admin-pub-key=/Users/lekva/.ssh/id_rsa.pub --from-ip=192.168.100.210 --to-ip=192.168.100.240 --storage-dir=/pcloud-storage/longhorn
 
 create_env:
 	./pcloud --kubeconfig=../../priv/kubeconfig create-env --admin-priv-key=/Users/lekva/.ssh/id_rsa --name=lekva --ip=192.168.0.211 --admin-username=gio
@@ -36,9 +36,28 @@
 build_arm64:
 	go build -o server_arm64 cmd/*.go
 
-push: clean build_arm64
+build_amd64: export CGO_ENABLED=0
+build_amd64: export GO111MODULE=on
+build_amd64: export GOOS=linux
+build_amd64: export GOARCH=amd64
+build_amd64:
+	go build -o server_amd64 cmd/*.go
+
+push_arm64: clean build_arm64
 	mkdir tmp
 	cp -r ../../charts tmp/
-	podman build --tag=giolekva/pcloud-installer:latest .
+	podman build --platform linux/arm64 --tag=giolekva/pcloud-installer:arm64 .
 	rm -rf tmp
-	podman push giolekva/pcloud-installer:latest
+	podman push giolekva/pcloud-installer:arm64
+
+push_amd64: clean build_amd64
+	mkdir tmp
+	cp -r ../../charts tmp/
+	podman build --platform linux/amd64 --tag=giolekva/pcloud-installer:amd64 .
+	rm -rf tmp
+	podman push giolekva/pcloud-installer:amd64
+
+push: push_arm64 push_amd64
+	podman manifest create giolekva/pcloud-installer:latest giolekva/pcloud-installer:arm64 giolekva/pcloud-installer:amd64
+	podman manifest push giolekva/pcloud-installer:latest docker://docker.io/giolekva/pcloud-installer:latest
+	podman manifest rm giolekva/pcloud-installer:latest
diff --git a/core/installer/bootstrapper.go b/core/installer/bootstrapper.go
new file mode 100644
index 0000000..957f1ab
--- /dev/null
+++ b/core/installer/bootstrapper.go
@@ -0,0 +1,509 @@
+package installer
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"log"
+	"net/netip"
+	"path/filepath"
+	"time"
+
+	"github.com/cenkalti/backoff/v4"
+	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/chart/loader"
+
+	"github.com/giolekva/pcloud/core/installer/soft"
+)
+
+const IPAddressPoolLocal = "local"
+const IPAddressPoolConfigRepo = "config-repo"
+const IPAddressPoolIngressPublic = "ingress-public"
+
+type Bootstrapper struct {
+	cl ChartLoader
+	ns NamespaceCreator
+	ha HelmActionConfigFactory
+}
+
+func NewBootstrapper(cl ChartLoader, ns NamespaceCreator, ha HelmActionConfigFactory) Bootstrapper {
+	return Bootstrapper{cl, ns, ha}
+}
+
+func (b Bootstrapper) Run(env EnvConfig) error {
+	bootstrapJobKeys, err := NewSSHKeyPair()
+	if err != nil {
+		return err
+	}
+	if err := b.installMetallb(env); err != nil {
+		return err
+	}
+	if err := b.installLonghorn(env.Name, env.StorageDir, env.VolumeDefaultReplicaCount); err != nil {
+		return err
+	}
+	time.Sleep(1 * time.Minute) // TODO(giolekva): implement proper wait
+	if err := b.installSoftServe(bootstrapJobKeys.Public, env.Name, env.ServiceIPs.ConfigRepo); err != nil {
+		return err
+	}
+	var ss *soft.Client
+	err = backoff.Retry(func() error {
+		var err error
+		ss, err = soft.NewClient(netip.AddrPortFrom(env.ServiceIPs.ConfigRepo, 22), []byte(bootstrapJobKeys.Private), log.Default())
+		return err
+	}, backoff.NewConstantBackOff(5*time.Second))
+	if err != nil {
+		return err
+	}
+	if ss.AddPublicKey("admin", string(env.AdminPublicKey)); err != nil {
+		return err
+	}
+	if err := b.installFluxcd(ss, env.Name); err != nil {
+		return err
+	}
+	repo, err := ss.GetRepo(env.Name)
+	if err != nil {
+		return err
+	}
+	repoIO := NewRepoIO(repo, ss.Signer)
+	if err := configureMainRepo(repoIO, env); err != nil {
+		return err
+	}
+	nsGen := NewPrefixGenerator(env.NamespacePrefix)
+	if err := b.installInfrastructureServices(repoIO, nsGen, b.ns, env); err != nil {
+		return err
+	}
+	if err := b.installEnvManager(ss, repoIO, nsGen, b.ns, env); err != nil {
+		return err
+	}
+	if ss.RemovePublicKey("admin", bootstrapJobKeys.Public); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b Bootstrapper) installMetallb(env EnvConfig) error {
+	if err := b.installMetallbNamespace(env); err != nil {
+		return err
+	}
+	if err := b.installMetallbService(); err != nil {
+		return err
+	}
+	if err := b.installMetallbIPAddressPool(IPAddressPoolLocal, true, env.ServiceIPs.From, env.ServiceIPs.To); err != nil {
+		return err
+	}
+	if err := b.installMetallbIPAddressPool(IPAddressPoolConfigRepo, false, env.ServiceIPs.ConfigRepo, env.ServiceIPs.ConfigRepo); err != nil {
+		return err
+	}
+	if err := b.installMetallbIPAddressPool(IPAddressPoolIngressPublic, false, env.ServiceIPs.IngressPublic, env.ServiceIPs.IngressPublic); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b Bootstrapper) installMetallbNamespace(env EnvConfig) error {
+	fmt.Println("Installing metallb namespace")
+	config, err := b.ha.New(env.Name)
+	if err != nil {
+		return err
+	}
+	chart, err := b.cl.Load("namespace")
+	if err != nil {
+		return err
+	}
+	values := map[string]any{
+		"namespace": "metallb-system",
+		"labels": []string{
+			"pod-security.kubernetes.io/audit: privileged",
+			"pod-security.kubernetes.io/enforce: privileged",
+			"pod-security.kubernetes.io/warn: privileged",
+		},
+	}
+	installer := action.NewInstall(config)
+	installer.Namespace = env.Name
+	installer.ReleaseName = "metallb-ns"
+	installer.Wait = true
+	installer.WaitForJobs = true
+	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b Bootstrapper) installMetallbService() error {
+	fmt.Println("Installing metallb")
+	config, err := b.ha.New("metallb-system")
+	if err != nil {
+		return err
+	}
+	chart, err := b.cl.Load("metallb")
+	if err != nil {
+		return err
+	}
+	values := map[string]any{ // TODO(giolekva): add loadBalancerClass?
+		"controller": map[string]any{
+			"image": map[string]any{
+				"repository": "quay.io/metallb/controller",
+				"tag":        "v0.13.9",
+				"pullPolicy": "IfNotPresent",
+			},
+			"logLevel": "info",
+		},
+		"speaker": map[string]any{
+			"image": map[string]any{
+				"repository": "quay.io/metallb/speaker",
+				"tag":        "v0.13.9",
+				"pullPolicy": "IfNotPresent",
+			},
+			"logLevel": "info",
+		},
+	}
+	installer := action.NewInstall(config)
+	installer.Namespace = "metallb-system"
+	installer.CreateNamespace = true
+	installer.ReleaseName = "metallb"
+	installer.IncludeCRDs = true
+	installer.Wait = true
+	installer.WaitForJobs = true
+	installer.Timeout = 20 * time.Minute
+	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b Bootstrapper) installMetallbIPAddressPool(name string, autoAssign bool, from, to netip.Addr) error {
+	fmt.Printf("Installing metallb-ipaddresspool: %s\n", name)
+	config, err := b.ha.New("metallb-system")
+	if err != nil {
+		return err
+	}
+	chart, err := b.cl.Load("metallb-ipaddresspool")
+	if err != nil {
+		return err
+	}
+	values := map[string]any{
+		"name":       name,
+		"autoAssign": autoAssign,
+		"from":       from.String(),
+		"to":         to.String(),
+	}
+	installer := action.NewInstall(config)
+	installer.Namespace = "metallb-system"
+	installer.CreateNamespace = true
+	installer.ReleaseName = name
+	installer.Wait = true
+	installer.WaitForJobs = true
+	installer.Timeout = 20 * time.Minute
+	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b Bootstrapper) installLonghorn(envName string, storageDir string, volumeDefaultReplicaCount int) error {
+	fmt.Println("Installing Longhorn")
+	config, err := b.ha.New(envName)
+	if err != nil {
+		return err
+	}
+	chart, err := b.cl.Load("longhorn")
+	if err != nil {
+		return err
+	}
+	values := map[string]any{
+		"defaultSettings": map[string]any{
+			"defaultDataPath": storageDir,
+		},
+		"persistence": map[string]any{
+			"defaultClassReplicaCount": volumeDefaultReplicaCount,
+		},
+		"service": map[string]any{
+			"ui": map[string]any{
+				"type": "LoadBalancer",
+			},
+		},
+		"ingress": map[string]any{
+			"enabled": false,
+		},
+	}
+	installer := action.NewInstall(config)
+	installer.Namespace = "longhorn-system"
+	installer.CreateNamespace = true
+	installer.ReleaseName = "longhorn"
+	installer.Wait = true
+	installer.WaitForJobs = true
+	installer.Timeout = 20 * time.Minute
+	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b Bootstrapper) installSoftServe(adminPublicKey string, envName string, repoIP netip.Addr) error {
+	fmt.Println("Installing SoftServe")
+	keys, err := NewSSHKeyPair()
+	if err != nil {
+		return err
+	}
+	config, err := b.ha.New(envName)
+	if err != nil {
+		return err
+	}
+	chart, err := b.cl.Load("soft-serve")
+	if err != nil {
+		return err
+	}
+	values := map[string]any{
+		"image": map[string]any{
+			"repository": "charmcli/soft-serve",
+			"tag":        "v0.5.4",
+			"pullPolicy": "IfNotPresent",
+		},
+		"privateKey": keys.Private,
+		"publicKey":  keys.Public,
+		"adminKey":   adminPublicKey,
+		"reservedIP": repoIP.String(),
+	}
+	installer := action.NewInstall(config)
+	installer.Namespace = envName
+	installer.CreateNamespace = true
+	installer.ReleaseName = "soft-serve"
+	installer.Wait = true
+	installer.WaitForJobs = true
+	installer.Timeout = 20 * time.Minute
+	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b Bootstrapper) installFluxcd(ss *soft.Client, envName string) error {
+	keys, err := NewSSHKeyPair()
+	if err != nil {
+		return err
+	}
+	if err := ss.AddUser("flux", keys.Public); err != nil {
+		return err
+	}
+	if err := ss.MakeUserAdmin("flux"); err != nil {
+		return err
+	}
+	fmt.Printf("Creating /%s repo", envName)
+	if err := ss.AddRepository(envName, "# dodo Systems"); err != nil {
+		return err
+	}
+	fmt.Println("Installing Flux")
+	ssPublic, err := ss.GetPublicKey()
+	if err != nil {
+		return err
+	}
+	if err := b.installFluxBootstrap(
+		ss.GetRepoAddress(envName),
+		ss.Addr.Addr().String(),
+		string(ssPublic),
+		keys.Private,
+		envName,
+	); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b Bootstrapper) installFluxBootstrap(repoAddr, repoHost, repoHostPubKey, privateKey, envName string) error {
+	config, err := b.ha.New(envName)
+	if err != nil {
+		return err
+	}
+	chart, err := b.cl.Load("flux-bootstrap")
+	if err != nil {
+		return err
+	}
+	values := map[string]any{
+		"image": map[string]any{
+			"repository": "fluxcd/flux-cli", // "giolekva/flux",
+			"tag":        "v2.0.0",
+			"pullPolicy": "IfNotPresent",
+		},
+		"repositoryAddress":       repoAddr,
+		"repositoryHost":          repoHost,
+		"repositoryHostPublicKey": repoHostPubKey,
+		"privateKey":              privateKey,
+		"installationNamespace":   fmt.Sprintf("%s-flux", envName),
+	}
+	installer := action.NewInstall(config)
+	installer.Namespace = envName
+	installer.CreateNamespace = true
+	installer.ReleaseName = "flux"
+	installer.Wait = true
+	installer.WaitForJobs = true
+	installer.Timeout = 20 * time.Minute
+	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b Bootstrapper) installInfrastructureServices(repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
+	appRepo := NewInMemoryAppRepository(CreateAllApps())
+	install := func(name string) error {
+		app, err := appRepo.Find(name)
+		if err != nil {
+			return err
+		}
+		namespaces := make([]string, len(app.Namespaces))
+		for i, n := range app.Namespaces {
+			namespaces[i], err = nsGen.Generate(n)
+			if err != nil {
+				return err
+			}
+		}
+		for _, n := range namespaces {
+			if err := nsCreator.Create(n); err != nil {
+				return err
+			}
+		}
+		derived := Derived{
+			Global: Values{
+				PCloudEnvName: env.Name,
+			},
+		}
+		if len(namespaces) > 0 {
+			derived.Release.Namespace = namespaces[0]
+		}
+		values := map[string]any{
+			"IngressPublicIP": env.ServiceIPs.IngressPublic.String(),
+		}
+		return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), values, derived)
+	}
+	appsToInstall := []string{
+		"resource-renderer-controller",
+		"headscale-controller",
+		"csi-driver-smb",
+		"ingress-public",
+		"cert-manager",
+		"cert-manager-webhook-gandi",
+		"cert-manager-webhook-gandi-role",
+	}
+	for _, name := range appsToInstall {
+		if err := install(name); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func configureMainRepo(repo RepoIO, env EnvConfig) error {
+	if err := repo.WriteYaml("config.yaml", env); err != nil {
+		return err
+	}
+	kust := NewKustomization()
+	kust.AddResources(
+		fmt.Sprintf("%s-flux", env.Name),
+		"infrastructure",
+		"environments",
+	)
+	if err := repo.WriteKustomization("kustomization.yaml", kust); err != nil {
+		return err
+	}
+	{
+		out, err := repo.Writer("infrastructure/pcloud-charts.yaml")
+		if err != nil {
+			return err
+		}
+		defer out.Close()
+		_, err = out.Write([]byte(fmt.Sprintf(`
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: GitRepository
+metadata:
+  name: pcloud # TODO(giolekva): use more generic name
+  namespace: %s
+spec:
+  interval: 1m0s
+  url: https://github.com/giolekva/pcloud
+  ref:
+    branch: main
+`, env.Name)))
+		if err != nil {
+			return err
+		}
+	}
+	infraKust := NewKustomization()
+	infraKust.AddResources("pcloud-charts.yaml")
+	if err := repo.WriteKustomization("infrastructure/kustomization.yaml", infraKust); err != nil {
+		return err
+	}
+	if err := repo.WriteKustomization("environments/kustomization.yaml", NewKustomization()); err != nil {
+		return err
+	}
+	if err := repo.CommitAndPush("initialize pcloud directory structure"); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b Bootstrapper) installEnvManager(ss *soft.Client, repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
+	keys, err := NewSSHKeyPair()
+	if err != nil {
+		return err
+	}
+	user := fmt.Sprintf("%s-env-manager", env.Name)
+	if err := ss.AddUser(user, keys.Public); err != nil {
+		return err
+	}
+	if err := ss.MakeUserAdmin(user); err != nil {
+		return err
+	}
+	appRepo := NewInMemoryAppRepository(CreateAllApps())
+	app, err := appRepo.Find("env-manager")
+	if err != nil {
+		return err
+	}
+	namespaces := make([]string, len(app.Namespaces))
+	for i, n := range app.Namespaces {
+		namespaces[i], err = nsGen.Generate(n)
+		if err != nil {
+			return err
+		}
+	}
+	for _, n := range namespaces {
+		if err := nsCreator.Create(n); err != nil {
+			return err
+		}
+	}
+	derived := Derived{
+		Global: Values{
+			PCloudEnvName: env.Name,
+		},
+		Values: map[string]any{
+			"RepoIP":        env.ServiceIPs.ConfigRepo,
+			"RepoPort":      22,
+			"RepoName":      env.Name,
+			"SSHPrivateKey": keys.Private,
+		},
+	}
+	if len(namespaces) > 0 {
+		derived.Release.Namespace = namespaces[0]
+	}
+	return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), derived.Values, derived)
+}
+
+type HelmActionConfigFactory interface {
+	New(namespace string) (*action.Configuration, error)
+}
+
+type ChartLoader interface {
+	Load(name string) (*chart.Chart, error)
+}
+
+type fsChartLoader struct {
+	baseDir string
+}
+
+func NewFSChartLoader(baseDir string) ChartLoader {
+	return &fsChartLoader{baseDir}
+}
+
+func (l *fsChartLoader) Load(name string) (*chart.Chart, error) {
+	return loader.Load(filepath.Join(l.baseDir, name))
+}
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index 6027204..a19a9fc 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -1,17 +1,13 @@
 package main
 
 import (
-	"net"
 	"os"
 
-	"github.com/go-git/go-billy/v5/memfs"
-	"github.com/go-git/go-git/v5"
-	gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
-	"github.com/go-git/go-git/v5/storage/memory"
 	"github.com/spf13/cobra"
 	"golang.org/x/crypto/ssh"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
 	"github.com/giolekva/pcloud/core/installer/welcome"
 )
 
@@ -63,7 +59,11 @@
 	if err != nil {
 		return err
 	}
-	repo, err := cloneRepo(appManagerFlags.repoAddr, signer)
+	addr, err := soft.ParseRepositoryAddress(appManagerFlags.repoAddr)
+	if err != nil {
+		return err
+	}
+	repo, err := soft.CloneRepo(addr, signer)
 	if err != nil {
 		return err
 	}
@@ -88,25 +88,3 @@
 	s.Start()
 	return nil
 }
-
-func cloneRepo(address string, signer ssh.Signer) (*git.Repository, error) {
-	return git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
-		URL:             address,
-		Auth:            auth(signer),
-		RemoteName:      "origin",
-		InsecureSkipTLS: true,
-	})
-}
-
-func auth(signer ssh.Signer) *gitssh.PublicKeys {
-	return &gitssh.PublicKeys{
-		Signer: signer,
-		HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{
-			HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
-				// TODO(giolekva): verify server public key
-				// fmt.Printf("## %s || %s -- \n", serverPubKey, ssh.MarshalAuthorizedKey(key))
-				return nil
-			},
-		},
-	}
-}
diff --git a/core/installer/cmd/bootstrap.go b/core/installer/cmd/bootstrap.go
index 19ec6c9..d177ff5 100644
--- a/core/installer/cmd/bootstrap.go
+++ b/core/installer/cmd/bootstrap.go
@@ -1,31 +1,27 @@
 package main
 
 import (
-	"context"
 	_ "embed"
 	"fmt"
-	"log"
+	"net/netip"
 	"os"
-	"path/filepath"
-	"time"
 
-	"github.com/cenkalti/backoff/v4"
 	"github.com/spf13/cobra"
 	"helm.sh/helm/v3/pkg/action"
-	"helm.sh/helm/v3/pkg/chart/loader"
 	"helm.sh/helm/v3/pkg/kube"
 
 	"github.com/giolekva/pcloud/core/installer"
-	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 var bootstrapFlags struct {
-	pcloudEnvName             string
+	envName                   string
+	publicIP                  string
 	chartsDir                 string
 	adminPubKey               string
 	storageDir                string
 	volumeDefaultReplicaCount int
-	softServeIP               string // TODO(giolekva): reserve using metallb IPAddressPool
+	fromIP                    string
+	toIP                      string
 }
 
 func bootstrapCmd() *cobra.Command {
@@ -34,12 +30,18 @@
 		RunE: bootstrapCmdRun,
 	}
 	cmd.Flags().StringVar(
-		&bootstrapFlags.pcloudEnvName,
-		"pcloud-env-name",
+		&bootstrapFlags.envName,
+		"env-name",
 		"pcloud",
 		"",
 	)
 	cmd.Flags().StringVar(
+		&bootstrapFlags.envName,
+		"public-ip",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
 		&bootstrapFlags.chartsDir,
 		"charts-dir",
 		"",
@@ -64,8 +66,14 @@
 		"",
 	)
 	cmd.Flags().StringVar(
-		&bootstrapFlags.softServeIP,
-		"soft-serve-ip",
+		&bootstrapFlags.fromIP,
+		"from-ip",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
+		&bootstrapFlags.toIP,
+		"to-ip",
 		"",
 		"",
 	)
@@ -77,457 +85,42 @@
 	if err != nil {
 		return err
 	}
-	bootstrapJobKeys, err := installer.NewSSHKeyPair()
-	if err != nil {
-		return err
-	}
-	if err := installMetallb(); err != nil {
-		return err
-	}
-	if err := installLonghorn(); err != nil {
-		return err
-	}
-	time.Sleep(2 * time.Minute) // TODO(giolekva): implement proper wait
-	if err := installSoftServe(bootstrapJobKeys.Public); err != nil {
-		return err
-	}
-	var ss *soft.Client
-	err = backoff.Retry(func() error {
-		var err error
-		ss, err = soft.NewClient(bootstrapFlags.softServeIP, 22, []byte(bootstrapJobKeys.Private), log.Default())
-		return err
-	}, backoff.NewConstantBackOff(5*time.Second))
-	if err != nil {
-		return err
-	}
-	if ss.AddPublicKey("admin", string(adminPubKey)); err != nil {
-		return err
-	}
-	if err := installFluxcd(ss, bootstrapFlags.pcloudEnvName); err != nil {
-		return err
-	}
-	repo, err := ss.GetRepo(bootstrapFlags.pcloudEnvName)
-	if err != nil {
-		return err
-	}
-	repoIO := installer.NewRepoIO(repo, ss.Signer)
-	if err := configurePCloudRepo(repoIO); err != nil {
-		return err
-	}
-	// TODO(giolekva): commit this to the repo above
-	global := installer.Values{
-		PCloudEnvName: bootstrapFlags.pcloudEnvName,
-	}
 	nsCreator, err := newNSCreator()
 	if err != nil {
 		return err
 	}
-	nsGen := installer.NewPrefixGenerator("pcloud-")
-	if err := installInfrastructureServices(repoIO, nsGen, nsCreator, global); err != nil {
+	serviceIPs, err := newServiceIPs(bootstrapFlags.fromIP, bootstrapFlags.toIP)
+	if err != nil {
 		return err
 	}
-	if err := installEnvManager(ss, repoIO, nsGen, nsCreator, global); err != nil {
-		return err
+	envConfig := installer.EnvConfig{
+		Name:                      bootstrapFlags.envName,
+		PublicIP:                  bootstrapFlags.publicIP,
+		NamespacePrefix:           fmt.Sprintf("%s-", bootstrapFlags.envName),
+		StorageDir:                bootstrapFlags.storageDir,
+		VolumeDefaultReplicaCount: bootstrapFlags.volumeDefaultReplicaCount,
+		AdminPublicKey:            adminPubKey,
+		ServiceIPs:                serviceIPs,
 	}
-	if ss.RemovePublicKey("admin", bootstrapJobKeys.Public); err != nil {
-		return err
-	}
-	return nil
+	b := installer.NewBootstrapper(
+		installer.NewFSChartLoader(bootstrapFlags.chartsDir),
+		nsCreator,
+		actionConfigFactory{rootFlags.kubeConfig},
+	)
+	return b.Run(envConfig)
 }
 
-func installMetallb() error {
-	if err := installMetallbNamespace(); err != nil {
-		return err
-	}
-	if err := installMetallbService(); err != nil {
-		return err
-	}
-	if err := installMetallbConfig(); err != nil {
-		return err
-	}
-	return nil
+type actionConfigFactory struct {
+	kubeConfigPath string
 }
 
-func installMetallbNamespace() error {
-	fmt.Println("Installing metallb namespace")
-	// config, err := createActionConfig("default")
-	config, err := createActionConfig(bootstrapFlags.pcloudEnvName)
-	if err != nil {
-		return err
-	}
-	chart, err := loader.Load(filepath.Join(bootstrapFlags.chartsDir, "namespace"))
-	if err != nil {
-		return err
-	}
-	values := map[string]interface{}{
-		// "namespace": "pcloud-metallb",
-		"namespace": "metallb-system",
-		"labels": []string{
-			"pod-security.kubernetes.io/audit: privileged",
-			"pod-security.kubernetes.io/enforce: privileged",
-			"pod-security.kubernetes.io/warn: privileged",
-		},
-	}
-	installer := action.NewInstall(config)
-	installer.Namespace = bootstrapFlags.pcloudEnvName
-	installer.ReleaseName = "metallb-ns"
-	installer.Wait = true
-	installer.WaitForJobs = true
-	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
-		return err
-	}
-	return nil
-}
-
-func installMetallbService() error {
-	fmt.Println("Installing metallb")
-	// config, err := createActionConfig("default")
-	config, err := createActionConfig("metallb-system")
-	if err != nil {
-		return err
-	}
-	chart, err := loader.Load(filepath.Join(bootstrapFlags.chartsDir, "metallb"))
-	if err != nil {
-		return err
-	}
-	values := map[string]interface{}{ // TODO(giolekva): add loadBalancerClass?
-		"controller": map[string]interface{}{
-			"image": map[string]interface{}{
-				"repository": "quay.io/metallb/controller",
-				"tag":        "v0.13.9",
-				"pullPolicy": "IfNotPresent",
-			},
-			"logLevel": "info",
-		},
-		"speaker": map[string]interface{}{
-			"image": map[string]interface{}{
-				"repository": "quay.io/metallb/speaker",
-				"tag":        "v0.13.9",
-				"pullPolicy": "IfNotPresent",
-			},
-			"logLevel": "info",
-		},
-	}
-	installer := action.NewInstall(config)
-	installer.Namespace = "metallb-system" // "pcloud-metallb"
-	installer.CreateNamespace = true
-	installer.ReleaseName = "metallb"
-	installer.IncludeCRDs = true
-	installer.Wait = true
-	installer.WaitForJobs = true
-	installer.Timeout = 20 * time.Minute
-	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
-		return err
-	}
-	return nil
-}
-
-func installMetallbConfig() error {
-	fmt.Println("Installing metallb-config")
-	// config, err := createActionConfig("default")
-	config, err := createActionConfig("metallb-system")
-	if err != nil {
-		return err
-	}
-	chart, err := loader.Load(filepath.Join(bootstrapFlags.chartsDir, "metallb-config"))
-	if err != nil {
-		return err
-	}
-	values := map[string]interface{}{
-		"from": "192.168.0.210",
-		"to":   "192.168.0.240",
-	}
-	installer := action.NewInstall(config)
-	installer.Namespace = "metallb-system" // "pcloud-metallb"
-	installer.CreateNamespace = true
-	installer.ReleaseName = "metallb-cfg"
-	installer.Wait = true
-	installer.WaitForJobs = true
-	installer.Timeout = 20 * time.Minute
-	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
-		return err
-	}
-	return nil
-}
-
-func installLonghorn() error {
-	fmt.Println("Installing Longhorn")
-	config, err := createActionConfig(bootstrapFlags.pcloudEnvName)
-	if err != nil {
-		return err
-	}
-	chart, err := loader.Load(filepath.Join(bootstrapFlags.chartsDir, "longhorn"))
-	if err != nil {
-		return err
-	}
-	values := map[string]interface{}{
-		"defaultSettings": map[string]interface{}{
-			"defaultDataPath": bootstrapFlags.storageDir,
-		},
-		"persistence": map[string]interface{}{
-			"defaultClassReplicaCount": bootstrapFlags.volumeDefaultReplicaCount,
-		},
-		"service": map[string]interface{}{
-			"ui": map[string]interface{}{
-				"type": "LoadBalancer",
-			},
-		},
-		"ingress": map[string]interface{}{
-			"enabled": false,
-		},
-	}
-	installer := action.NewInstall(config)
-	installer.Namespace = "longhorn-system"
-	installer.CreateNamespace = true
-	installer.ReleaseName = "longhorn"
-	installer.Wait = true
-	installer.WaitForJobs = true
-	installer.Timeout = 20 * time.Minute
-	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
-		return err
-	}
-	return nil
-}
-
-func installSoftServe(adminPublicKey string) error {
-	fmt.Println("Installing SoftServe")
-	keys, err := installer.NewSSHKeyPair()
-	if err != nil {
-		return err
-	}
-	config, err := createActionConfig(bootstrapFlags.pcloudEnvName)
-	if err != nil {
-		return err
-	}
-	chart, err := loader.Load(filepath.Join(bootstrapFlags.chartsDir, "soft-serve"))
-	if err != nil {
-		return err
-	}
-	values := map[string]any{
-		"image": map[string]any{
-			"repository": "charmcli/soft-serve",
-			"tag":        "v0.5.4",
-			"pullPolicy": "IfNotPresent",
-		},
-		"privateKey": keys.Private,
-		"publicKey":  keys.Public,
-		"adminKey":   adminPublicKey,
-		"reservedIP": bootstrapFlags.softServeIP,
-	}
-	installer := action.NewInstall(config)
-	installer.Namespace = bootstrapFlags.pcloudEnvName
-	installer.CreateNamespace = true
-	installer.ReleaseName = "soft-serve"
-	installer.Wait = true
-	installer.WaitForJobs = true
-	installer.Timeout = 20 * time.Minute
-	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
-		return err
-	}
-	return nil
-}
-
-func installFluxcd(ss *soft.Client, pcloudEnvName string) error {
-	keys, err := installer.NewSSHKeyPair()
-	if err != nil {
-		return err
-	}
-	if err := ss.AddUser("flux", keys.Public); err != nil {
-		return err
-	}
-	if err := ss.MakeUserAdmin("flux"); err != nil {
-		return err
-	}
-	fmt.Printf("Creating /%s repo", pcloudEnvName)
-	if err := ss.AddRepository(pcloudEnvName, "# PCloud Systems"); err != nil {
-		return err
-	}
-	fmt.Println("Installing Flux")
-	ssPublic, err := ss.GetPublicKey()
-	if err != nil {
-		return err
-	}
-	if err := installFluxBootstrap(
-		ss.GetRepoAddress(pcloudEnvName),
-		ss.IP,
-		string(ssPublic),
-		keys.Private,
-	); err != nil {
-		return err
-	}
-	return nil
-}
-
-func installFluxBootstrap(repoAddr, repoHost, repoHostPubKey, privateKey string) error {
-	config, err := createActionConfig(bootstrapFlags.pcloudEnvName)
-	if err != nil {
-		return err
-	}
-	chart, err := loader.Load(filepath.Join(bootstrapFlags.chartsDir, "flux-bootstrap"))
-	if err != nil {
-		return err
-	}
-	values := map[string]any{
-		"image": map[string]any{
-			"repository": "giolekva/flux",
-			"tag":        "2.0.0",
-			"pullPolicy": "IfNotPresent",
-		},
-		"repositoryAddress":       repoAddr,
-		"repositoryHost":          repoHost,
-		"repositoryHostPublicKey": repoHostPubKey,
-		"privateKey":              privateKey,
-	}
-	installer := action.NewInstall(config)
-	installer.Namespace = bootstrapFlags.pcloudEnvName
-	installer.CreateNamespace = true
-	installer.ReleaseName = "flux"
-	installer.Wait = true
-	installer.WaitForJobs = true
-	installer.Timeout = 20 * time.Minute
-	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
-		return err
-	}
-	return nil
-}
-
-func installInfrastructureServices(repo installer.RepoIO, nsGen installer.NamespaceGenerator, nsCreator installer.NamespaceCreator, global installer.Values) error {
-	appRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
-	install := func(name string) error {
-		app, err := appRepo.Find(name)
-		if err != nil {
-			return err
-		}
-		namespaces := make([]string, len(app.Namespaces))
-		for i, n := range app.Namespaces {
-			namespaces[i], err = nsGen.Generate(n)
-			if err != nil {
-				return err
-			}
-		}
-		for _, n := range namespaces {
-			if err := nsCreator.Create(n); err != nil {
-				return err
-			}
-		}
-		derived := installer.Derived{
-			Global: global,
-		}
-		if len(namespaces) > 0 {
-			derived.Release.Namespace = namespaces[0]
-		}
-		return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), map[string]any{}, derived)
-	}
-	appsToInstall := []string{
-		"resource-renderer-controller",
-		"headscale-controller",
-		"csi-driver-smb",
-		"ingress-public",
-		"cert-manager",
-		"cert-manager-webhook-gandi",
-		"cert-manager-webhook-gandi-role",
-	}
-	for _, name := range appsToInstall {
-		if err := install(name); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-func configurePCloudRepo(repo installer.RepoIO) error {
-	{
-		kust := installer.NewKustomization()
-		kust.AddResources("pcloud-flux", "infrastructure", "environments")
-		if err := repo.WriteKustomization("kustomization.yaml", kust); err != nil {
-			return err
-		}
-		{
-			out, err := repo.Writer("infrastructure/pcloud-charts.yaml")
-			if err != nil {
-				return err
-			}
-			defer out.Close()
-			_, err = out.Write([]byte(`
-apiVersion: source.toolkit.fluxcd.io/v1
-kind: GitRepository
-metadata:
-  name: pcloud # TODO(giolekva): use more generic name
-  namespace: pcloud # TODO(giolekva): configurable
-spec:
-  interval: 1m0s
-  url: https://github.com/giolekva/pcloud
-  ref:
-    branch: main
-`))
-			if err != nil {
-				return err
-			}
-		}
-		infraKust := installer.NewKustomization()
-		infraKust.AddResources("pcloud-charts.yaml")
-		if err := repo.WriteKustomization("infrastructure/kustomization.yaml", infraKust); err != nil {
-			return err
-		}
-		if err := repo.WriteKustomization("environments/kustomization.yaml", installer.NewKustomization()); err != nil {
-			return err
-		}
-		if err := repo.CommitAndPush("initialize pcloud directory structure"); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-func installEnvManager(ss *soft.Client, repo installer.RepoIO, nsGen installer.NamespaceGenerator, nsCreator installer.NamespaceCreator, global installer.Values) error {
-	keys, err := installer.NewSSHKeyPair()
-	if err != nil {
-		return err
-	}
-	user := fmt.Sprintf("%s-env-manager", bootstrapFlags.pcloudEnvName)
-	if err := ss.AddUser(user, keys.Public); err != nil {
-		return err
-	}
-	if err := ss.MakeUserAdmin(user); err != nil {
-		return err
-	}
-	appRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
-	app, err := appRepo.Find("env-manager")
-	if err != nil {
-		return err
-	}
-	namespaces := make([]string, len(app.Namespaces))
-	for i, n := range app.Namespaces {
-		namespaces[i], err = nsGen.Generate(n)
-		if err != nil {
-			return err
-		}
-	}
-	for _, n := range namespaces {
-		if err := nsCreator.Create(n); err != nil {
-			return err
-		}
-	}
-	derived := installer.Derived{
-		Global: global,
-		Values: map[string]any{
-			"RepoIP":        bootstrapFlags.softServeIP,
-			"SSHPrivateKey": keys.Private,
-		},
-	}
-	if len(namespaces) > 0 {
-		derived.Release.Namespace = namespaces[0]
-	}
-	return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), derived.Values, derived)
-}
-
-func createActionConfig(namespace string) (*action.Configuration, error) {
+func (f actionConfigFactory) New(namespace string) (*action.Configuration, error) {
 	config := new(action.Configuration)
 	if err := config.Init(
-		kube.GetConfig(rootFlags.kubeConfig, "", namespace),
+		kube.GetConfig(f.kubeConfigPath, "", namespace),
 		namespace,
 		"",
-		func(fmtString string, args ...interface{}) {
+		func(fmtString string, args ...any) {
 			fmt.Printf(fmtString, args...)
 			fmt.Println()
 		},
@@ -536,3 +129,23 @@
 	}
 	return config, nil
 }
+
+func newServiceIPs(from, to string) (installer.EnvServiceIPs, error) {
+	f, err := netip.ParseAddr(from)
+	if err != nil {
+		return installer.EnvServiceIPs{}, err
+	}
+	t, err := netip.ParseAddr(to)
+	if err != nil {
+		return installer.EnvServiceIPs{}, err
+	}
+	configRepo := f
+	ingressPublic := configRepo.Next()
+	restFrom := ingressPublic.Next()
+	return installer.EnvServiceIPs{
+		ConfigRepo:    configRepo,
+		IngressPublic: ingressPublic,
+		From:          restFrom,
+		To:            t,
+	}, nil
+}
diff --git a/core/installer/cmd/env_manager.go b/core/installer/cmd/env_manager.go
index a74369d..a77061a 100644
--- a/core/installer/cmd/env_manager.go
+++ b/core/installer/cmd/env_manager.go
@@ -2,6 +2,7 @@
 
 import (
 	"log"
+	"net/netip"
 	"os"
 
 	"github.com/spf13/cobra"
@@ -12,8 +13,8 @@
 )
 
 var envManagerFlags struct {
-	repoIP   string
-	repoPort int
+	repoAddr string
+	repoName string
 	sshKey   string
 	port     int
 }
@@ -24,15 +25,15 @@
 		RunE: envManagerCmdRun,
 	}
 	cmd.Flags().StringVar(
-		&envManagerFlags.repoIP,
-		"repo-ip",
+		&envManagerFlags.repoAddr,
+		"repo-addr",
 		"",
 		"",
 	)
-	cmd.Flags().IntVar(
-		&envManagerFlags.repoPort,
-		"repo-port",
-		22,
+	cmd.Flags().StringVar(
+		&envManagerFlags.repoName,
+		"repo-name",
+		"",
 		"",
 	)
 	cmd.Flags().StringVar(
@@ -55,11 +56,15 @@
 	if err != nil {
 		return err
 	}
-	ss, err := soft.NewClient(envManagerFlags.repoIP, envManagerFlags.repoPort, sshKey, log.Default())
+	repoAddr, err := netip.ParseAddrPort(envManagerFlags.repoAddr)
 	if err != nil {
 		return err
 	}
-	repo, err := ss.GetRepo("pcloud")
+	ss, err := soft.NewClient(repoAddr, sshKey, log.Default())
+	if err != nil {
+		return err
+	}
+	repo, err := ss.GetRepo(envManagerFlags.repoName)
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/cmd/welcome.go b/core/installer/cmd/welcome.go
index 6d2d7e0..0a38d13 100644
--- a/core/installer/cmd/welcome.go
+++ b/core/installer/cmd/welcome.go
@@ -1,18 +1,13 @@
 package main
 
 import (
-	"fmt"
 	"golang.org/x/crypto/ssh"
-	"net"
 	"os"
 
-	"github.com/go-git/go-billy/v5/memfs"
-	"github.com/go-git/go-git/v5"
-	gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
-	"github.com/go-git/go-git/v5/storage/memory"
 	"github.com/spf13/cobra"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
 	"github.com/giolekva/pcloud/core/installer/welcome"
 )
 
@@ -53,38 +48,27 @@
 	if err != nil {
 		return err
 	}
-	auth := authSSH(sshKey)
-	repo, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
-		URL:             welcomeFlags.repo,
-		Auth:            auth,
-		RemoteName:      "origin",
-		ReferenceName:   "refs/heads/master",
-		Depth:           1,
-		InsecureSkipTLS: true,
-		Progress:        os.Stdout,
-	})
+	signer, err := ssh.ParsePrivateKey(sshKey)
+	if err != nil {
+		return err
+	}
+	addr, err := soft.ParseRepositoryAddress(welcomeFlags.repo)
+	if err != nil {
+		return err
+	}
+	repo, err := soft.CloneRepo(addr, signer)
+	if err != nil {
+		return err
+	}
 	nsCreator, err := newNSCreator()
 	if err != nil {
 		return err
 	}
 	s := welcome.NewServer(
 		welcomeFlags.port,
-		installer.NewRepoIO(repo, auth.Signer),
+		installer.NewRepoIO(repo, signer),
 		nsCreator,
 	)
 	s.Start()
 	return nil
 }
-
-func authSSH(pemBytes []byte) *gitssh.PublicKeys {
-	a, err := gitssh.NewPublicKeys("git", pemBytes, "")
-	if err != nil {
-		panic(err)
-	}
-	a.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
-		// TODO(giolekva): verify server public key
-		fmt.Printf("--- %+v\n", ssh.MarshalAuthorizedKey(key))
-		return nil
-	}
-	return a
-}
diff --git a/core/installer/config.go b/core/installer/config.go
index 9f547ff..f800aff 100644
--- a/core/installer/config.go
+++ b/core/installer/config.go
@@ -1,5 +1,26 @@
 package installer
 
+import (
+	"net/netip"
+)
+
+type EnvServiceIPs struct {
+	ConfigRepo    netip.Addr `json:"configRepo"`
+	IngressPublic netip.Addr `json:"ingressPublic"`
+	From          netip.Addr `json:"from"`
+	To            netip.Addr `json:"to"`
+}
+
+type EnvConfig struct {
+	Name                      string        `json:"name"`
+	PublicIP                  string        `json:"publicIP"`
+	NamespacePrefix           string        `json:"namespacePrefix"`
+	StorageDir                string        `json:"storageDir"`
+	VolumeDefaultReplicaCount int           `json:"volumeDefaultReplicaCount"`
+	AdminPublicKey            []byte        `json:"adminPublicKey"`
+	ServiceIPs                EnvServiceIPs `json:"serviceIPs"`
+}
+
 type Config struct {
 	Values Values `json:"values"`
 }
diff --git a/core/installer/repoio.go b/core/installer/repoio.go
index ec7981c..41c479f 100644
--- a/core/installer/repoio.go
+++ b/core/installer/repoio.go
@@ -7,6 +7,7 @@
 	"io/fs"
 	"io/ioutil"
 	"net"
+	"net/netip"
 	"path"
 	"path/filepath"
 	"time"
@@ -17,9 +18,12 @@
 	gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
 	"golang.org/x/crypto/ssh"
 	"sigs.k8s.io/yaml"
+
+	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 type RepoIO interface {
+	Addr() netip.AddrPort
 	Fetch() error
 	ReadConfig() (Config, error)
 	ReadAppConfig(path string) (AppConfig, error)
@@ -39,17 +43,21 @@
 }
 
 type repoIO struct {
-	repo   *git.Repository
+	repo   *soft.Repository
 	signer ssh.Signer
 }
 
-func NewRepoIO(repo *git.Repository, signer ssh.Signer) RepoIO {
+func NewRepoIO(repo *soft.Repository, signer ssh.Signer) RepoIO {
 	return &repoIO{
 		repo,
 		signer,
 	}
 }
 
+func (r *repoIO) Addr() netip.AddrPort {
+	return r.repo.Addr.Addr
+}
+
 func (r *repoIO) Fetch() error {
 	err := r.repo.Fetch(&git.FetchOptions{
 		RemoteName: "origin",
diff --git a/core/installer/soft/client.go b/core/installer/soft/client.go
index 2a820a0..635ee0e 100644
--- a/core/installer/soft/client.go
+++ b/core/installer/soft/client.go
@@ -5,7 +5,9 @@
 	"golang.org/x/crypto/ssh"
 	"log"
 	"net"
+	"net/netip"
 	"os"
+	"regexp"
 	"strings"
 
 	"github.com/go-git/go-billy/v5/memfs"
@@ -15,14 +17,13 @@
 )
 
 type Client struct {
-	IP       string
-	port     int
+	Addr     netip.AddrPort
 	Signer   ssh.Signer
 	log      *log.Logger
 	pemBytes []byte
 }
 
-func NewClient(ip string, port int, clientPrivateKey []byte, log *log.Logger) (*Client, error) {
+func NewClient(addr netip.AddrPort, clientPrivateKey []byte, log *log.Logger) (*Client, error) {
 	signer, err := ssh.ParsePrivateKey(clientPrivateKey)
 	if err != nil {
 		return nil, err
@@ -30,8 +31,7 @@
 	log.SetPrefix("SOFT-SERVE: ")
 	log.Printf("Created signer")
 	return &Client{
-		ip,
-		port,
+		addr,
 		signer,
 		log,
 		clientPrivateKey,
@@ -64,7 +64,7 @@
 func (ss *Client) RunCommand(args ...string) error {
 	cmd := strings.Join(args, " ")
 	log.Printf("Running command %s", cmd)
-	client, err := ssh.Dial("tcp", ss.addressSSH(), ss.sshClientConfig())
+	client, err := ssh.Dial("tcp", ss.Addr.String(), ss.sshClientConfig())
 	if err != nil {
 		return err
 	}
@@ -88,18 +88,66 @@
 	return ss.RunCommand("repo", "collab", "add", repo, user)
 }
 
-func (ss *Client) GetRepo(name string) (*git.Repository, error) {
-	return git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
-		URL:             ss.GetRepoAddress(name),
-		Auth:            ss.authSSH(),
+type Repository struct {
+	*git.Repository
+	Addr RepositoryAddress
+}
+
+func (ss *Client) GetRepo(name string) (*Repository, error) {
+	return CloneRepo(RepositoryAddress{ss.Addr, name}, ss.Signer)
+}
+
+type RepositoryAddress struct {
+	Addr netip.AddrPort
+	Name string
+}
+
+func ParseRepositoryAddress(addr string) (RepositoryAddress, error) {
+	items := regexp.MustCompile(`ssh://.*)/(.*)`).FindStringSubmatch(addr)
+	if len(items) != 2 {
+		return RepositoryAddress{}, fmt.Errorf("Invalid address")
+	}
+	ipPort, err := netip.ParseAddrPort(items[1])
+	if err != nil {
+		return RepositoryAddress{}, err
+	}
+	return RepositoryAddress{ipPort, items[2]}, nil
+}
+
+func (r RepositoryAddress) FullAddress() string {
+	return fmt.Sprintf("ssh://%s/%s", r.Addr, r.Name)
+}
+
+func CloneRepo(addr RepositoryAddress, signer ssh.Signer) (*Repository, error) {
+	c, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
+		URL: addr.FullAddress(),
+		Auth: &gitssh.PublicKeys{
+			User:   "git",
+			Signer: signer,
+			HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{
+				HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
+					// TODO(giolekva): verify server public key
+					fmt.Printf("--- %+v\n", ssh.MarshalAuthorizedKey(key))
+					return nil
+				},
+			},
+		},
 		RemoteName:      "origin",
 		ReferenceName:   "refs/heads/master",
 		Depth:           1,
 		InsecureSkipTLS: true,
 		Progress:        os.Stdout,
 	})
+	if err != nil {
+		return nil, err
+	}
+	return &Repository{
+		Repository: c,
+		Addr:       addr,
+	}, nil
 }
 
+// TODO(giolekva): dead code
 func (ss *Client) authSSH() gitssh.AuthMethod {
 	a, err := gitssh.NewPublicKeys("git", ss.pemBytes, "")
 	if err != nil {
@@ -149,7 +197,7 @@
 			return nil
 		},
 	}
-	_, err := ssh.Dial("tcp", ss.addressSSH(), config)
+	_, err := ssh.Dial("tcp", ss.Addr.String(), config)
 	if err != nil {
 		return nil, err
 	}
@@ -175,9 +223,5 @@
 }
 
 func (ss *Client) addressGit() string {
-	return fmt.Sprintf("ssh://%s", ss.addressSSH())
-}
-
-func (ss *Client) addressSSH() string {
-	return fmt.Sprintf("%s:%d", ss.IP, ss.port)
+	return fmt.Sprintf("ssh://%s", ss.Addr)
 }
diff --git a/core/installer/soft/config.go b/core/installer/soft/config.go
deleted file mode 100644
index b674377..0000000
--- a/core/installer/soft/config.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package soft
-
-type Repository struct {
-	Name       string `json:"name"`
-	Repository string `json:"repo"`
-	Private    bool   `json:"private"`
-	Note       string `json:"note"`
-}
-
-type User struct {
-	Name       string   `json:"name"`
-	Admin      bool     `json:"admin"`
-	PublicKeys []string `json:"public-keys"`
-}
-
-type Config struct {
-	Name         string       `json:"name"`
-	Host         string       `json:"host"`
-	Port         int          `json:"port"`
-	AnonAccess   string       `json:"anon-access"`
-	AllowKeyless bool         `json:"allow-keyless"`
-	Repositories []Repository `json:"repos"`
-	Users        []User       `json:"users"`
-}
-
-func DefaultConfig(adminKeys []string) Config {
-	return Config{
-		Name:         "PCloud",
-		Host:         "localhost",
-		Port:         22,
-		AnonAccess:   "no-access",
-		AllowKeyless: false,
-		Repositories: []Repository{
-			{
-				Name:       "Home",
-				Repository: "config",
-				Private:    true,
-				Note:       "Configuration for PCloud SoftServe deployment",
-			},
-		},
-		Users: []User{
-			{
-				Name:       "Admin",
-				Admin:      true,
-				PublicKeys: adminKeys,
-			},
-		},
-	}
-}
diff --git a/core/installer/values-tmpl/env-manager.yaml b/core/installer/values-tmpl/env-manager.yaml
index a2ba684..7271dff 100644
--- a/core/installer/values-tmpl/env-manager.yaml
+++ b/core/installer/values-tmpl/env-manager.yaml
@@ -14,5 +14,7 @@
   interval: 1m0s
   values:
     repoIP: {{ .Values.RepoIP }}
+    repoPort: {{ .Values.RepoPort }}
+    repoName: {{ .Values.RepoName }}
     sshPrivateKey: {{ .Values.SSHPrivateKey | b64enc }}
     clusterRoleName: {{ .Global.PCloudEnvName }}-env-manager
diff --git a/core/installer/values-tmpl/ingress-public.yaml b/core/installer/values-tmpl/ingress-public.yaml
index 67ac1b2..94773e8 100644
--- a/core/installer/values-tmpl/ingress-public.yaml
+++ b/core/installer/values-tmpl/ingress-public.yaml
@@ -18,7 +18,7 @@
       service:
         type: LoadBalancer
         annotations:
-          metallb.universe.tf/loadBalancerIPs: 192.168.0.213 # TODO(giolekva): configurable
+          metallb.universe.tf/loadBalancerIPs: {{ .Values.IngressPublicIP }}
       ingressClassByName: true
       ingressClassResource:
         name: {{ .Global.PCloudEnvName }}-ingress-public
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index 22713f9..6fc03dc 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -303,7 +303,7 @@
 	if err != nil {
 		return err
 	}
-	repoIP := "192.168.0.211" // TODO(giolekva): configure
+	repoIP := repoIO.Addr().Addr().String()
 	for _, tmpl := range tmpls.Templates() {
 		dstPath := path.Join("environments", req.Name, tmpl.Name())
 		dst, err := repoIO.Writer(dstPath)