Installer: Separate infrastructure and environment apps.

Have two separate application managers, one for installing apps on the
dodo infra, and nother installing on individual environments.

Change-Id: I1b24f008e30c5533c48c22ea92328bc4bb7abc54
diff --git a/core/installer/Makefile b/core/installer/Makefile
index e36755b..332a889 100644
--- a/core/installer/Makefile
+++ b/core/installer/Makefile
@@ -26,7 +26,7 @@
 	/usr/local/go/bin/go test -v ./...
 
 bootstrap:
-	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner 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
+	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner 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 --public-ip=135.181.48.180,65.108.39.172
 
 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
diff --git a/core/installer/app.go b/core/installer/app.go
index 35c6d11..18f9e75 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -1,67 +1,17 @@
 package installer
 
 import (
-	"archive/tar"
 	"bytes"
-	"compress/gzip"
-	"embed"
 	"encoding/json"
 	"fmt"
 	template "html/template"
-	"io"
-	"log"
-	"net/http"
+	"net"
 	"strings"
 
 	"cuelang.org/go/cue"
-	"cuelang.org/go/cue/cuecontext"
 	cueyaml "cuelang.org/go/encoding/yaml"
-	"github.com/go-git/go-billy/v5"
-	"sigs.k8s.io/yaml"
 )
 
-//go:embed values-tmpl
-var valuesTmpls embed.FS
-
-var storeAppConfigs = []string{
-	"values-tmpl/jellyfin.cue",
-	// "values-tmpl/maddy.cue",
-	"values-tmpl/matrix.cue",
-	"values-tmpl/penpot.cue",
-	"values-tmpl/pihole.cue",
-	"values-tmpl/qbittorrent.cue",
-	"values-tmpl/rpuppy.cue",
-	"values-tmpl/soft-serve.cue",
-	"values-tmpl/vaultwarden.cue",
-	"values-tmpl/url-shortener.cue",
-	"values-tmpl/gerrit.cue",
-	"values-tmpl/jenkins.cue",
-	"values-tmpl/zot.cue",
-}
-
-var infraAppConfigs = []string{
-	"values-tmpl/appmanager.cue",
-	"values-tmpl/cert-manager.cue",
-	"values-tmpl/certificate-issuer-private.cue",
-	"values-tmpl/certificate-issuer-public.cue",
-	"values-tmpl/config-repo.cue",
-	"values-tmpl/core-auth.cue",
-	"values-tmpl/csi-driver-smb.cue",
-	"values-tmpl/dns-zone-manager.cue",
-	"values-tmpl/env-manager.cue",
-	"values-tmpl/fluxcd-reconciler.cue",
-	"values-tmpl/headscale-controller.cue",
-	"values-tmpl/headscale-user.cue",
-	"values-tmpl/headscale.cue",
-	"values-tmpl/ingress-public.cue",
-	"values-tmpl/metallb-ipaddresspool.cue",
-	"values-tmpl/private-network.cue",
-	"values-tmpl/resource-renderer-controller.cue",
-	"values-tmpl/welcome.cue",
-	"values-tmpl/memberships.cue",
-	"values-tmpl/hydra-maester.cue",
-}
-
 // TODO(gio): import
 const cueBaseConfig = `
 name: string | *""
@@ -131,6 +81,7 @@
 }
 
 #Release: {
+	appInstanceId: string
 	namespace: string
 	repoAddr: string
 	appDir: string
@@ -309,27 +260,64 @@
 }
 `
 
-type appConfig struct {
-	Name        string        `json:"name"`
-	Version     string        `json:"version"`
-	Description string        `json:"description"`
-	Namespaces  []string      `json:"namespaces"`
-	Icon        template.HTML `json:"icon"`
-}
-
 type Rendered struct {
+	Name      string
 	Readme    string
 	Resources map[string][]byte
 	Ports     []PortForward
+	Config    AppInstanceConfig
 }
 
+type PortForward struct {
+	Allocator     string `json:"allocator"`
+	Protocol      string `json:"protocol"`
+	SourcePort    int    `json:"sourcePort"`
+	TargetService string `json:"targetService"`
+	TargetPort    int    `json:"targetPort"`
+}
+
+type AppType int
+
+const (
+	AppTypeInfra AppType = iota
+	AppTypeEnv
+)
+
 type App interface {
+	Type() AppType
 	Name() string
 	Description() string
 	Icon() template.HTML
 	Schema() Schema
 	Namespace() string
-	Render(derived Derived) (Rendered, error)
+}
+
+type InfraConfig struct {
+	Name                 string   `json:"pcloudEnvName"` // #TODO(gio): change to name
+	PublicIP             []net.IP `json:"publicIP"`
+	InfraNamespacePrefix string   `json:"namespacePrefix"`
+	InfraAdminPublicKey  []byte   `json:"infraAdminPublicKey"`
+}
+
+type InfraApp interface {
+	App
+	Render(release Release, infra InfraConfig, values map[string]any) (Rendered, error)
+}
+
+// TODO(gio): rename to EnvConfig
+type AppEnvConfig struct {
+	Id              string   `json:"id"`
+	InfraName       string   `json:"pcloudEnvName"`
+	Domain          string   `json:"domain"`
+	PrivateDomain   string   `json:"privateDomain"`
+	ContactEmail    string   `json:"contactEmail"`
+	PublicIP        []net.IP `json:"publicIP"`
+	NamespacePrefix string   `json:"namespacePrefix"`
+}
+
+type EnvApp interface {
+	App
+	Render(release Release, env AppEnvConfig, values map[string]any) (Rendered, error)
 }
 
 type cueApp struct {
@@ -341,18 +329,16 @@
 	cfg         *cue.Value
 }
 
-type cueAppConfig struct {
-	Name        string `json:"name"`
-	Namespace   string `json:"namespace"`
-	Description string `json:"description"`
-	Icon        string `json:"icon"`
-}
-
 func newCueApp(config *cue.Value) (cueApp, error) {
 	if config == nil {
 		return cueApp{}, fmt.Errorf("config not provided")
 	}
-	var cfg cueAppConfig
+	cfg := struct {
+		Name        string `json:"name"`
+		Namespace   string `json:"namespace"`
+		Description string `json:"description"`
+		Icon        string `json:"icon"`
+	}{}
 	if err := config.Decode(&cfg); err != nil {
 		return cueApp{}, err
 	}
@@ -390,21 +376,14 @@
 	return a.namespace
 }
 
-type PortForward struct {
-	Allocator     string `json:"allocator"`
-	Protocol      string `json:"protocol"`
-	SourcePort    int    `json:"sourcePort"`
-	TargetService string `json:"targetService"`
-	TargetPort    int    `json:"targetPort"`
-}
-
-func (a cueApp) Render(derived Derived) (Rendered, error) {
+func (a cueApp) render(values map[string]any) (Rendered, error) {
 	ret := Rendered{
+		Name:      a.Name(),
 		Resources: make(map[string][]byte),
 		Ports:     make([]PortForward, 0),
 	}
 	var buf bytes.Buffer
-	if err := json.NewEncoder(&buf).Encode(derived); err != nil {
+	if err := json.NewEncoder(&buf).Encode(values); err != nil {
 		return Rendered{}, err
 	}
 	ctx := a.cfg.Context()
@@ -440,254 +419,70 @@
 	return ret, nil
 }
 
-type AppRepository interface {
-	GetAll() ([]App, error)
-	Find(name string) (App, error)
+type cueEnvApp struct {
+	cueApp
 }
 
-type InMemoryAppRepository struct {
-	apps []App
-}
-
-func NewInMemoryAppRepository(apps []App) InMemoryAppRepository {
-	return InMemoryAppRepository{apps}
-}
-
-func (r InMemoryAppRepository) Find(name string) (App, error) {
-	for _, a := range r.apps {
-		if a.Name() == name {
-			return a, nil
-		}
-	}
-	return nil, fmt.Errorf("Application not found: %s", name)
-}
-
-func (r InMemoryAppRepository) GetAll() ([]App, error) {
-	return r.apps, nil
-}
-
-func CreateAllApps() []App {
-	return append(
-		createApps(infraAppConfigs),
-		CreateStoreApps()...,
-	)
-}
-
-func CreateStoreApps() []App {
-	return createApps(storeAppConfigs)
-}
-
-func createApps(configs []string) []App {
-	ret := make([]App, 0)
-	for _, cfgFile := range configs {
-		cfg, err := readCueConfigFromFile(valuesTmpls, cfgFile)
-		if err != nil {
-			panic(err)
-		}
-		if app, err := newCueApp(cfg); err != nil {
-			panic(err)
-		} else {
-			ret = append(ret, app)
-		}
-	}
-	return ret
-}
-
-// func CreateAppMaddy(fs embed.FS, tmpls *template.Template) App {
-// 	schema, err := readJSONSchemaFromFile(fs, "values-tmpl/maddy.jsonschema")
-// 	if err != nil {
-// 		panic(err)
-// 	}
-// 	return StoreApp{
-// 		App{
-// 			"maddy",
-// 			[]string{"app-maddy"},
-// 			[]*template.Template{
-// 				tmpls.Lookup("maddy.yaml"),
-// 			},
-// 			schema,
-// 			nil,
-// 			nil,
-// 		},
-// 		`<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 48 48"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M9.5 13c13.687 13.574 14.825 13.09 29 0"/><rect width="37" height="31" x="5.5" y="8.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" rx="2"/></svg>`,
-// 		"SMPT/IMAP server to communicate via email.",
-// 	}
-// }
-
-type httpAppRepository struct {
-	apps []App
-}
-
-type appVersion struct {
-	Version string   `json:"version"`
-	Urls    []string `json:"urls"`
-}
-
-type allAppsResp struct {
-	ApiVersion string                  `json:"apiVersion"`
-	Entries    map[string][]appVersion `json:"entries"`
-}
-
-func FetchAppsFromHTTPRepository(addr string, fs billy.Filesystem) error {
-	resp, err := http.Get(addr)
-	if err != nil {
-		return err
-	}
-	b, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return err
-	}
-	var apps allAppsResp
-	if err := yaml.Unmarshal(b, &apps); err != nil {
-		return err
-	}
-	for name, conf := range apps.Entries {
-		for _, version := range conf {
-			resp, err := http.Get(version.Urls[0])
-			if err != nil {
-				return err
-			}
-			nameVersion := fmt.Sprintf("%s-%s", name, version.Version)
-			if err := fs.MkdirAll(nameVersion, 0700); err != nil {
-				return err
-			}
-			sub, err := fs.Chroot(nameVersion)
-			if err != nil {
-				return err
-			}
-			if err := extractApp(resp.Body, sub); err != nil {
-				return err
-			}
-		}
-	}
-	return nil
-}
-
-func extractApp(archive io.Reader, fs billy.Filesystem) error {
-	uncompressed, err := gzip.NewReader(archive)
-	if err != nil {
-		return err
-	}
-	tarReader := tar.NewReader(uncompressed)
-	for true {
-		header, err := tarReader.Next()
-		if err == io.EOF {
-			break
-		}
-		if err != nil {
-			return err
-		}
-		switch header.Typeflag {
-		case tar.TypeDir:
-			if err := fs.MkdirAll(header.Name, 0755); err != nil {
-				return err
-			}
-		case tar.TypeReg:
-			out, err := fs.Create(header.Name)
-			if err != nil {
-				return err
-			}
-			defer out.Close()
-			if _, err := io.Copy(out, tarReader); err != nil {
-				return err
-			}
-		default:
-			return fmt.Errorf("Uknown type: %s", header.Name)
-		}
-	}
-	return nil
-}
-
-type fsAppRepository struct {
-	InMemoryAppRepository
-	fs billy.Filesystem
-}
-
-func NewFSAppRepository(fs billy.Filesystem) (AppRepository, error) {
-	all, err := fs.ReadDir(".")
+func NewCueEnvApp(config *cue.Value) (EnvApp, error) {
+	app, err := newCueApp(config)
 	if err != nil {
 		return nil, err
 	}
-	apps := make([]App, 0)
-	for _, e := range all {
-		if !e.IsDir() {
-			continue
-		}
-		appFS, err := fs.Chroot(e.Name())
-		if err != nil {
-			return nil, err
-		}
-		app, err := loadApp(appFS)
-		if err != nil {
-			log.Printf("Ignoring directory %s: %s", e.Name(), err)
-			continue
-		}
-		apps = append(apps, app)
-	}
-	return &fsAppRepository{
-		NewInMemoryAppRepository(apps),
-		fs,
-	}, nil
+	return cueEnvApp{app}, nil
 }
 
-func loadApp(fs billy.Filesystem) (App, error) {
-	items, err := fs.ReadDir(".")
+func (a cueEnvApp) Type() AppType {
+	return AppTypeEnv
+}
+
+func (a cueEnvApp) Render(release Release, env AppEnvConfig, values map[string]any) (Rendered, error) {
+	networks := CreateNetworks(env)
+	derived, err := deriveValues(values, a.Schema(), networks)
+	if err != nil {
+		return Rendered{}, nil
+	}
+	ret, err := a.cueApp.render(map[string]any{
+		"global":  env,
+		"release": release,
+		"input":   derived,
+	})
+	if err != nil {
+		return Rendered{}, err
+	}
+	ret.Config = AppInstanceConfig{
+		AppId:   a.Name(),
+		Env:     env,
+		Release: release,
+		Values:  values,
+		Input:   derived,
+	}
+	return ret, nil
+}
+
+type cueInfraApp struct {
+	cueApp
+}
+
+func NewCueInfraApp(config *cue.Value) (InfraApp, error) {
+	app, err := newCueApp(config)
 	if err != nil {
 		return nil, err
 	}
-	var contents bytes.Buffer
-	for _, i := range items {
-		if i.IsDir() {
-			continue
-		}
-		f, err := fs.Open(i.Name())
-		if err != nil {
-			return nil, err
-		}
-		defer f.Close()
-		if _, err := io.Copy(&contents, f); err != nil {
-			return nil, err
-		}
-	}
-	cfg, err := processCueConfig(contents.String())
-	if err != nil {
-		return nil, err
-	}
-	return newCueApp(cfg)
+	return cueInfraApp{app}, nil
+}
+
+func (a cueInfraApp) Type() AppType {
+	return AppTypeInfra
+}
+
+func (a cueInfraApp) Render(release Release, infra InfraConfig, values map[string]any) (Rendered, error) {
+	return a.cueApp.render(map[string]any{
+		"global":  infra,
+		"release": release,
+		"input":   values,
+	})
 }
 
 func cleanName(s string) string {
 	return strings.ReplaceAll(strings.ReplaceAll(s, "\"", ""), "'", "")
 }
-
-func processCueConfig(contents string) (*cue.Value, error) {
-	ctx := cuecontext.New()
-	cfg := ctx.CompileString(contents + cueBaseConfig)
-	if err := cfg.Err(); err != nil {
-		return nil, err
-	}
-	if err := cfg.Validate(); err != nil {
-		return nil, err
-	}
-	return &cfg, nil
-}
-
-func readCueConfigFromFile(fs embed.FS, f string) (*cue.Value, error) {
-	contents, err := fs.ReadFile(f)
-	if err != nil {
-		return nil, err
-	}
-	return processCueConfig(string(contents))
-}
-
-func createApp(fs embed.FS, configFile string) App {
-	cfg, err := readCueConfigFromFile(fs, configFile)
-	if err != nil {
-		panic(err)
-	}
-	if app, err := newCueApp(cfg); err != nil {
-		panic(err)
-	} else {
-		return app
-	}
-}
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index e438a26..7e89eec 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -14,7 +14,7 @@
 	"sigs.k8s.io/yaml"
 )
 
-const appDir = "/apps"
+const appDirRoot = "/apps"
 const configFileName = "config.yaml"
 const kustomizationFileName = "kustomization.yaml"
 
@@ -30,32 +30,32 @@
 	}, nil
 }
 
-func (m *AppManager) Config() (Config, error) {
-	var cfg Config
+func (m *AppManager) Config() (AppEnvConfig, error) {
+	var cfg AppEnvConfig
 	if err := ReadYaml(m.repoIO, configFileName, &cfg); err != nil {
-		return Config{}, err
+		return AppEnvConfig{}, err
 	} else {
 		return cfg, nil
 	}
 }
 
-func (m *AppManager) appConfig(path string) (AppConfig, error) {
-	var cfg AppConfig
+func (m *AppManager) appConfig(path string) (AppInstanceConfig, error) {
+	var cfg AppInstanceConfig
 	if err := ReadYaml(m.repoIO, path, &cfg); err != nil {
-		return AppConfig{}, err
+		return AppInstanceConfig{}, err
 	} else {
 		return cfg, nil
 	}
 }
 
-func (m *AppManager) FindAllInstances(name string) ([]AppConfig, error) {
-	kust, err := ReadKustomization(m.repoIO, filepath.Join(appDir, "kustomization.yaml"))
+func (m *AppManager) FindAllInstances(name string) ([]AppInstanceConfig, error) {
+	kust, err := ReadKustomization(m.repoIO, filepath.Join(appDirRoot, "kustomization.yaml"))
 	if err != nil {
 		return nil, err
 	}
-	ret := make([]AppConfig, 0)
+	ret := make([]AppInstanceConfig, 0)
 	for _, app := range kust.Resources {
-		cfg, err := m.appConfig(filepath.Join(appDir, app, "config.yaml"))
+		cfg, err := m.appConfig(filepath.Join(appDirRoot, app, "config.yaml"))
 		if err != nil {
 			return nil, err
 		}
@@ -67,34 +67,34 @@
 	return ret, nil
 }
 
-func (m *AppManager) FindInstance(id string) (AppConfig, error) {
-	kust, err := ReadKustomization(m.repoIO, filepath.Join(appDir, "kustomization.yaml"))
+func (m *AppManager) FindInstance(id string) (AppInstanceConfig, error) {
+	kust, err := ReadKustomization(m.repoIO, filepath.Join(appDirRoot, "kustomization.yaml"))
 	if err != nil {
-		return AppConfig{}, err
+		return AppInstanceConfig{}, err
 	}
 	for _, app := range kust.Resources {
 		if app == id {
-			cfg, err := m.appConfig(filepath.Join(appDir, app, "config.yaml"))
+			cfg, err := m.appConfig(filepath.Join(appDirRoot, app, "config.yaml"))
 			if err != nil {
-				return AppConfig{}, err
+				return AppInstanceConfig{}, err
 			}
 			cfg.Id = id
 			return cfg, nil
 		}
 	}
-	return AppConfig{}, nil
+	return AppInstanceConfig{}, nil
 }
 
-func (m *AppManager) AppConfig(name string) (AppConfig, error) {
-	configF, err := m.repoIO.Reader(filepath.Join(appDir, name, configFileName))
+func (m *AppManager) AppConfig(name string) (AppInstanceConfig, error) {
+	configF, err := m.repoIO.Reader(filepath.Join(appDirRoot, name, configFileName))
 	if err != nil {
-		return AppConfig{}, err
+		return AppInstanceConfig{}, err
 	}
 	defer configF.Close()
-	var cfg AppConfig
+	var cfg AppInstanceConfig
 	contents, err := ioutil.ReadAll(configF)
 	if err != nil {
-		return AppConfig{}, err
+		return AppInstanceConfig{}, err
 	}
 	err = yaml.UnmarshalStrict(contents, &cfg)
 	return cfg, err
@@ -152,19 +152,8 @@
 	return nil
 }
 
-func InstallApp(repo RepoIO, nsc NamespaceCreator, app App, appDir string, namespace string, initValues map[string]any, derived Derived) error {
-	if err := nsc.Create(namespace); err != nil {
-		return err
-	}
-	derived.Release = Release{
-		Namespace: namespace,
-		RepoAddr:  repo.FullAddress(),
-		AppDir:    appDir,
-	}
-	rendered, err := app.Render(derived)
-	if err != nil {
-		return err
-	}
+// TODO(gio): rename to CommitApp
+func InstallApp(repo RepoIO, appDir string, rendered Rendered) error {
 	if err := openPorts(rendered.Ports); err != nil {
 		return err
 	}
@@ -179,12 +168,7 @@
 			if err := r.CreateDir(appDir); err != nil {
 				return "", err
 			}
-			cfg := AppConfig{
-				AppId:   app.Name(),
-				Config:  initValues,
-				Derived: derived,
-			}
-			if err := WriteYaml(r, path.Join(appDir, configFileName), cfg); err != nil {
+			if err := WriteYaml(r, path.Join(appDir, configFileName), rendered.Config); err != nil {
 				return "", err
 			}
 		}
@@ -205,54 +189,61 @@
 				return "", err
 			}
 		}
-		return fmt.Sprintf("install: %s", app.Name()), nil
+		return fmt.Sprintf("install: %s", rendered.Name), nil
 	})
 }
 
-func (m *AppManager) Install(app App, appDir string, namespace string, values map[string]any) error {
+// TODO(gio): commit instanceId -> appDir mapping as well
+func (m *AppManager) Install(app EnvApp, instanceId string, appDir string, namespace string, values map[string]any) error {
 	appDir = filepath.Clean(appDir)
 	if err := m.repoIO.Pull(); err != nil {
 		return err
 	}
-	globalConfig, err := m.Config()
+	if err := m.nsCreator.Create(namespace); err != nil {
+		return err
+	}
+	env, err := m.Config()
 	if err != nil {
 		return err
 	}
-	derivedValues, err := deriveValues(values, app.Schema(), CreateNetworks(globalConfig))
+	release := Release{
+		AppInstanceId: instanceId,
+		Namespace:     namespace,
+		RepoAddr:      m.repoIO.FullAddress(),
+		AppDir:        appDir,
+	}
+	rendered, err := app.Render(release, env, values)
 	if err != nil {
 		return err
 	}
-	derived := Derived{
-		Global: globalConfig.Values,
-		Values: derivedValues,
-	}
-	return InstallApp(m.repoIO, m.nsCreator, app, appDir, namespace, values, derived)
+	return InstallApp(m.repoIO, appDir, rendered)
 }
 
-func (m *AppManager) Update(app App, instanceId string, config map[string]any) error {
+func (m *AppManager) Update(app EnvApp, instanceId string, values map[string]any) error {
 	if err := m.repoIO.Pull(); err != nil {
 		return err
 	}
-	globalConfig, err := m.Config()
+	env, err := m.Config()
 	if err != nil {
 		return err
 	}
-	instanceDir := filepath.Join(appDir, instanceId)
+	instanceDir := filepath.Join(appDirRoot, instanceId)
 	instanceConfigPath := filepath.Join(instanceDir, configFileName)
-	appConfig, err := m.appConfig(instanceConfigPath)
+	config, err := m.appConfig(instanceConfigPath)
 	if err != nil {
 		return err
 	}
-	derivedValues, err := deriveValues(config, app.Schema(), CreateNetworks(globalConfig))
+	release := Release{
+		AppInstanceId: instanceId,
+		Namespace:     config.Release.Namespace,
+		RepoAddr:      m.repoIO.FullAddress(),
+		AppDir:        instanceDir,
+	}
+	rendered, err := app.Render(release, env, values)
 	if err != nil {
 		return err
 	}
-	derived := Derived{
-		Global:  globalConfig.Values,
-		Release: appConfig.Derived.Release,
-		Values:  derivedValues,
-	}
-	return InstallApp(m.repoIO, m.nsCreator, app, instanceDir, appConfig.Derived.Release.Namespace, config, derived)
+	return InstallApp(m.repoIO, instanceDir, rendered)
 }
 
 func (m *AppManager) Remove(instanceId string) error {
@@ -260,8 +251,8 @@
 		return err
 	}
 	return m.repoIO.Atomic(func(r RepoFS) (string, error) {
-		r.RemoveDir(filepath.Join(appDir, instanceId))
-		kustPath := filepath.Join(appDir, "kustomization.yaml")
+		r.RemoveDir(filepath.Join(appDirRoot, instanceId))
+		kustPath := filepath.Join(appDirRoot, "kustomization.yaml")
 		kust, err := ReadKustomization(r, kustPath)
 		if err != nil {
 			return "", err
@@ -273,20 +264,67 @@
 }
 
 // TODO(gio): deduplicate with cue definition in app.go, this one should be removed.
-func CreateNetworks(global Config) []Network {
+func CreateNetworks(env AppEnvConfig) []Network {
 	return []Network{
 		{
 			Name:              "Public",
-			IngressClass:      fmt.Sprintf("%s-ingress-public", global.Values.PCloudEnvName),
-			CertificateIssuer: fmt.Sprintf("%s-public", global.Values.Id),
-			Domain:            global.Values.Domain,
-			AllocatePortAddr:  fmt.Sprintf("http://port-allocator.%s-ingress-public/api/allocate", global.Values.PCloudEnvName),
+			IngressClass:      fmt.Sprintf("%s-ingress-public", env.InfraName),
+			CertificateIssuer: fmt.Sprintf("%s-public", env.Id),
+			Domain:            env.Domain,
+			AllocatePortAddr:  fmt.Sprintf("http://port-allocator.%s-ingress-public.svc.cluster.local/api/allocate", env.InfraName),
 		},
 		{
 			Name:             "Private",
-			IngressClass:     fmt.Sprintf("%s-ingress-private", global.Values.Id),
-			Domain:           global.Values.PrivateDomain,
-			AllocatePortAddr: fmt.Sprintf("http://port-allocator.%s-ingress-private/api/allocate", global.Values.Id),
+			IngressClass:     fmt.Sprintf("%s-ingress-private", env.Id),
+			Domain:           env.PrivateDomain,
+			AllocatePortAddr: fmt.Sprintf("http://port-allocator.%s-ingress-private.svc.cluster.local/api/allocate", env.Id),
 		},
 	}
 }
+
+// InfraAppmanager
+
+type InfraAppManager struct {
+	repoIO    RepoIO
+	nsCreator NamespaceCreator
+}
+
+func NewInfraAppManager(repoIO RepoIO, nsCreator NamespaceCreator) (*InfraAppManager, error) {
+	return &InfraAppManager{
+		repoIO,
+		nsCreator,
+	}, nil
+}
+
+func (m *InfraAppManager) Config() (InfraConfig, error) {
+	var cfg InfraConfig
+	if err := ReadYaml(m.repoIO, configFileName, &cfg); err != nil {
+		return InfraConfig{}, err
+	} else {
+		return cfg, nil
+	}
+}
+
+func (m *InfraAppManager) Install(app InfraApp, appDir string, namespace string, values map[string]any) error {
+	appDir = filepath.Clean(appDir)
+	if err := m.repoIO.Pull(); err != nil {
+		return err
+	}
+	if err := m.nsCreator.Create(namespace); err != nil {
+		return err
+	}
+	infra, err := m.Config()
+	if err != nil {
+		return err
+	}
+	release := Release{
+		Namespace: namespace,
+		RepoAddr:  m.repoIO.FullAddress(),
+		AppDir:    appDir,
+	}
+	rendered, err := app.Render(release, infra, values)
+	if err != nil {
+		return err
+	}
+	return InstallApp(m.repoIO, appDir, rendered)
+}
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
new file mode 100644
index 0000000..70cdb8e
--- /dev/null
+++ b/core/installer/app_repository.go
@@ -0,0 +1,332 @@
+package installer
+
+import (
+	"archive/tar"
+	"bytes"
+	"compress/gzip"
+	"embed"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+
+	"cuelang.org/go/cue"
+	"cuelang.org/go/cue/cuecontext"
+	"github.com/go-git/go-billy/v5"
+	"sigs.k8s.io/yaml"
+)
+
+//go:embed values-tmpl
+var valuesTmpls embed.FS
+
+var storeAppConfigs = []string{
+	"values-tmpl/jellyfin.cue",
+	// "values-tmpl/maddy.cue",
+	"values-tmpl/matrix.cue",
+	"values-tmpl/penpot.cue",
+	"values-tmpl/pihole.cue",
+	"values-tmpl/qbittorrent.cue",
+	"values-tmpl/rpuppy.cue",
+	"values-tmpl/soft-serve.cue",
+	"values-tmpl/vaultwarden.cue",
+	"values-tmpl/url-shortener.cue",
+	"values-tmpl/gerrit.cue",
+	"values-tmpl/jenkins.cue",
+	"values-tmpl/zot.cue",
+	// TODO(gio): should be part of env infra
+	"values-tmpl/certificate-issuer-private.cue",
+	"values-tmpl/certificate-issuer-public.cue",
+	"values-tmpl/appmanager.cue",
+	"values-tmpl/core-auth.cue",
+	"values-tmpl/headscale-user.cue",
+	"values-tmpl/metallb-ipaddresspool.cue",
+	"values-tmpl/private-network.cue",
+	"values-tmpl/welcome.cue",
+	"values-tmpl/memberships.cue",
+	"values-tmpl/headscale.cue",
+}
+
+var infraAppConfigs = []string{
+	"values-tmpl/cert-manager.cue",
+	"values-tmpl/config-repo.cue",
+	"values-tmpl/csi-driver-smb.cue",
+	"values-tmpl/dns-zone-manager.cue",
+	"values-tmpl/env-manager.cue",
+	"values-tmpl/fluxcd-reconciler.cue",
+	"values-tmpl/headscale-controller.cue",
+	"values-tmpl/ingress-public.cue",
+	"values-tmpl/resource-renderer-controller.cue",
+	"values-tmpl/hydra-maester.cue",
+}
+
+type AppRepository interface {
+	GetAll() ([]App, error)
+	Find(name string) (App, error)
+}
+
+type InMemoryAppRepository struct {
+	apps []App
+}
+
+func NewInMemoryAppRepository(apps []App) InMemoryAppRepository {
+	return InMemoryAppRepository{apps}
+}
+
+func (r InMemoryAppRepository) Find(name string) (App, error) {
+	for _, a := range r.apps {
+		if a.Name() == name {
+			return a, nil
+		}
+	}
+	return nil, fmt.Errorf("Application not found: %s", name)
+}
+
+func (r InMemoryAppRepository) GetAll() ([]App, error) {
+	return r.apps, nil
+}
+
+func CreateAllApps() []App {
+	return append(
+		createInfraApps(),
+		CreateStoreApps()...,
+	)
+}
+
+func CreateStoreApps() []App {
+	ret := make([]App, 0)
+	for _, cfgFile := range storeAppConfigs {
+		cfg, err := readCueConfigFromFile(valuesTmpls, cfgFile)
+		if err != nil {
+			panic(err)
+		}
+		if app, err := NewCueEnvApp(cfg); err != nil {
+			panic(err)
+		} else {
+			ret = append(ret, app)
+		}
+	}
+	return ret
+}
+
+func createInfraApps() []App {
+	ret := make([]App, 0)
+	for _, cfgFile := range infraAppConfigs {
+		cfg, err := readCueConfigFromFile(valuesTmpls, cfgFile)
+		if err != nil {
+			panic(err)
+		}
+		if app, err := NewCueInfraApp(cfg); err != nil {
+			panic(err)
+		} else {
+			ret = append(ret, app)
+		}
+	}
+	return ret
+}
+
+type httpAppRepository struct {
+	apps []App
+}
+
+type appVersion struct {
+	Version string   `json:"version"`
+	Urls    []string `json:"urls"`
+}
+
+type allAppsResp struct {
+	ApiVersion string                  `json:"apiVersion"`
+	Entries    map[string][]appVersion `json:"entries"`
+}
+
+func FetchAppsFromHTTPRepository(addr string, fs billy.Filesystem) error {
+	resp, err := http.Get(addr)
+	if err != nil {
+		return err
+	}
+	b, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+	var apps allAppsResp
+	if err := yaml.Unmarshal(b, &apps); err != nil {
+		return err
+	}
+	for name, conf := range apps.Entries {
+		for _, version := range conf {
+			resp, err := http.Get(version.Urls[0])
+			if err != nil {
+				return err
+			}
+			nameVersion := fmt.Sprintf("%s-%s", name, version.Version)
+			if err := fs.MkdirAll(nameVersion, 0700); err != nil {
+				return err
+			}
+			sub, err := fs.Chroot(nameVersion)
+			if err != nil {
+				return err
+			}
+			if err := extractApp(resp.Body, sub); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func extractApp(archive io.Reader, fs billy.Filesystem) error {
+	uncompressed, err := gzip.NewReader(archive)
+	if err != nil {
+		return err
+	}
+	tarReader := tar.NewReader(uncompressed)
+	for true {
+		header, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return err
+		}
+		switch header.Typeflag {
+		case tar.TypeDir:
+			if err := fs.MkdirAll(header.Name, 0755); err != nil {
+				return err
+			}
+		case tar.TypeReg:
+			out, err := fs.Create(header.Name)
+			if err != nil {
+				return err
+			}
+			defer out.Close()
+			if _, err := io.Copy(out, tarReader); err != nil {
+				return err
+			}
+		default:
+			return fmt.Errorf("Uknown type: %s", header.Name)
+		}
+	}
+	return nil
+}
+
+type fsAppRepository struct {
+	InMemoryAppRepository
+	fs billy.Filesystem
+}
+
+func NewFSAppRepository(fs billy.Filesystem) (AppRepository, error) {
+	all, err := fs.ReadDir(".")
+	if err != nil {
+		return nil, err
+	}
+	apps := make([]App, 0)
+	for _, e := range all {
+		if !e.IsDir() {
+			continue
+		}
+		appFS, err := fs.Chroot(e.Name())
+		if err != nil {
+			return nil, err
+		}
+		app, err := loadApp(appFS)
+		if err != nil {
+			log.Printf("Ignoring directory %s: %s", e.Name(), err)
+			continue
+		}
+		apps = append(apps, app)
+	}
+	return &fsAppRepository{
+		NewInMemoryAppRepository(apps),
+		fs,
+	}, nil
+}
+
+func loadApp(fs billy.Filesystem) (App, error) {
+	items, err := fs.ReadDir(".")
+	if err != nil {
+		return nil, err
+	}
+	var contents bytes.Buffer
+	for _, i := range items {
+		if i.IsDir() {
+			continue
+		}
+		f, err := fs.Open(i.Name())
+		if err != nil {
+			return nil, err
+		}
+		defer f.Close()
+		if _, err := io.Copy(&contents, f); err != nil {
+			return nil, err
+		}
+	}
+	cfg, err := processCueConfig(contents.String())
+	if err != nil {
+		return nil, err
+	}
+	return NewCueEnvApp(cfg)
+}
+
+func readCueConfigFromFile(fs embed.FS, f string) (*cue.Value, error) {
+	contents, err := fs.ReadFile(f)
+	if err != nil {
+		return nil, err
+	}
+	return processCueConfig(string(contents))
+}
+
+func processCueConfig(contents string) (*cue.Value, error) {
+	ctx := cuecontext.New()
+	cfg := ctx.CompileString(contents + cueBaseConfig)
+	if err := cfg.Err(); err != nil {
+		return nil, err
+	}
+	if err := cfg.Validate(); err != nil {
+		return nil, err
+	}
+	return &cfg, nil
+}
+
+// func CreateAppMaddy(fs embed.FS, tmpls *template.Template) App {
+// 	schema, err := readJSONSchemaFromFile(fs, "values-tmpl/maddy.jsonschema")
+// 	if err != nil {
+// 		panic(err)
+// 	}
+// 	return StoreApp{
+// 		App{
+// 			"maddy",
+// 			[]string{"app-maddy"},
+// 			[]*template.Template{
+// 				tmpls.Lookup("maddy.yaml"),
+// 			},
+// 			schema,
+// 			nil,
+// 			nil,
+// 		},
+// 		`<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 48 48"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M9.5 13c13.687 13.574 14.825 13.09 29 0"/><rect width="37" height="31" x="5.5" y="8.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" rx="2"/></svg>`,
+// 		"SMPT/IMAP server to communicate via email.",
+// 	}
+// }
+
+func FindEnvApp(r AppRepository, name string) (EnvApp, error) {
+	app, err := r.Find(name)
+	if err != nil {
+		return nil, err
+	}
+	if a, ok := app.(EnvApp); ok {
+		return a, nil
+	} else {
+		return nil, fmt.Errorf("not found")
+	}
+}
+
+func FindInfraApp(r AppRepository, name string) (InfraApp, error) {
+	app, err := r.Find(name)
+	if err != nil {
+		return nil, err
+	}
+	if a, ok := app.(InfraApp); ok {
+		return a, nil
+	} else {
+		return nil, fmt.Errorf("not found")
+	}
+}
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 4524a30..a979342 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -1,47 +1,41 @@
 package installer
 
 import (
+	"net"
 	"testing"
 )
 
 func TestAuthProxyEnabled(t *testing.T) {
 	r := NewInMemoryAppRepository(CreateAllApps())
 	for _, app := range []string{"rpuppy", "Pi-hole", "url-shortener"} {
-		a, err := r.Find(app)
+		a, err := FindEnvApp(r, app)
 		if err != nil {
 			t.Fatal(err)
 		}
 		if a == nil {
 			t.Fatal("returned app is nil")
 		}
-		d := Derived{
-			Release: Release{
-				Namespace: "foo",
-			},
-			Global: Values{
-				PCloudEnvName:   "dodo",
-				Id:              "id",
-				ContactEmail:    "foo@bar.ge",
-				Domain:          "bar.ge",
-				PrivateDomain:   "p.bar.ge",
-				PublicIP:        "1.2.3.4",
-				NamespacePrefix: "id-",
-			},
-			Values: map[string]any{
-				"network": map[string]any{
-					"name":              "Public",
-					"ingressClass":      "dodo-ingress-public",
-					"certificateIssuer": "id-public",
-					"domain":            "bar.ge",
-				},
-				"subdomain": "woof",
-				"auth": map[string]any{
-					"enabled": true,
-					"groups":  "a,b",
-				},
+		release := Release{
+			Namespace: "foo",
+		}
+		env := AppEnvConfig{
+			InfraName:       "dodo",
+			Id:              "id",
+			ContactEmail:    "foo@bar.ge",
+			Domain:          "bar.ge",
+			PrivateDomain:   "p.bar.ge",
+			PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
+			NamespacePrefix: "id-",
+		}
+		values := map[string]any{
+			"network":   "Public",
+			"subdomain": "woof",
+			"auth": map[string]any{
+				"enabled": true,
+				"groups":  "a,b",
 			},
 		}
-		rendered, err := a.Render(d)
+		rendered, err := a.Render(release, env, values)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -54,40 +48,33 @@
 func TestAuthProxyDisabled(t *testing.T) {
 	r := NewInMemoryAppRepository(CreateAllApps())
 	for _, app := range []string{"rpuppy", "Pi-hole", "url-shortener"} {
-		a, err := r.Find(app)
+		a, err := FindEnvApp(r, app)
 		if err != nil {
 			t.Fatal(err)
 		}
 		if a == nil {
 			t.Fatal("returned app is nil")
 		}
-		d := Derived{
-			Release: Release{
-				Namespace: "foo",
-			},
-			Global: Values{
-				PCloudEnvName:   "dodo",
-				Id:              "id",
-				ContactEmail:    "foo@bar.ge",
-				Domain:          "bar.ge",
-				PrivateDomain:   "p.bar.ge",
-				PublicIP:        "1.2.3.4",
-				NamespacePrefix: "id-",
-			},
-			Values: map[string]any{
-				"network": map[string]any{
-					"name":              "Public",
-					"ingressClass":      "dodo-ingress-public",
-					"certificateIssuer": "id-public",
-					"domain":            "bar.ge",
-				},
-				"subdomain": "woof",
-				"auth": map[string]any{
-					"enabled": false,
-				},
+		release := Release{
+			Namespace: "foo",
+		}
+		env := AppEnvConfig{
+			InfraName:       "dodo",
+			Id:              "id",
+			ContactEmail:    "foo@bar.ge",
+			Domain:          "bar.ge",
+			PrivateDomain:   "p.bar.ge",
+			PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
+			NamespacePrefix: "id-",
+		}
+		values := map[string]any{
+			"network":   "Public",
+			"subdomain": "woof",
+			"auth": map[string]any{
+				"enabled": false,
 			},
 		}
-		rendered, err := a.Render(d)
+		rendered, err := a.Render(release, env, values)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -99,29 +86,27 @@
 
 func TestGroupMemberships(t *testing.T) {
 	r := NewInMemoryAppRepository(CreateAllApps())
-	a, err := r.Find("memberships")
+	a, err := FindEnvApp(r, "memberships")
 	if err != nil {
 		t.Fatal(err)
 	}
 	if a == nil {
 		t.Fatal("returned app is nil")
 	}
-	d := Derived{
-		Release: Release{
-			Namespace: "foo",
-		},
-		Global: Values{
-			PCloudEnvName:   "dodo",
-			Id:              "id",
-			ContactEmail:    "foo@bar.ge",
-			Domain:          "bar.ge",
-			PrivateDomain:   "p.bar.ge",
-			PublicIP:        "1.2.3.4",
-			NamespacePrefix: "id-",
-		},
-		Values: map[string]any{},
+	release := Release{
+		Namespace: "foo",
 	}
-	rendered, err := a.Render(d)
+	env := AppEnvConfig{
+		InfraName:       "dodo",
+		Id:              "id",
+		ContactEmail:    "foo@bar.ge",
+		Domain:          "bar.ge",
+		PrivateDomain:   "p.bar.ge",
+		PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
+		NamespacePrefix: "id-",
+	}
+	values := map[string]any{}
+	rendered, err := a.Render(release, env, values)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -132,42 +117,35 @@
 
 func TestGerrit(t *testing.T) {
 	r := NewInMemoryAppRepository(CreateAllApps())
-	a, err := r.Find("gerrit")
+	a, err := FindEnvApp(r, "gerrit")
 	if err != nil {
 		t.Fatal(err)
 	}
 	if a == nil {
 		t.Fatal("returned app is nil")
 	}
-	d := Derived{
-		Release: Release{
-			Namespace: "foo",
-		},
-		Global: Values{
-			PCloudEnvName:   "dodo",
-			Id:              "id",
-			ContactEmail:    "foo@bar.ge",
-			Domain:          "bar.ge",
-			PrivateDomain:   "p.bar.ge",
-			PublicIP:        "1.2.3.4",
-			NamespacePrefix: "id-",
-		},
-		Values: map[string]any{
-			"subdomain": "gerrit",
-			"network": map[string]any{
-				"name":             "Private",
-				"ingressClass":     "id-ingress-private",
-				"domain":           "p.bar.ge",
-				"allocatePortAddr": "http://foo.bar/api/allocate",
-			},
-			"key": map[string]any{
-				"public":  "foo",
-				"private": "bar",
-			},
-			"sshPort": 22,
-		},
+	release := Release{
+		Namespace: "foo",
 	}
-	rendered, err := a.Render(d)
+	env := AppEnvConfig{
+		InfraName:       "dodo",
+		Id:              "id",
+		ContactEmail:    "foo@bar.ge",
+		Domain:          "bar.ge",
+		PrivateDomain:   "p.bar.ge",
+		PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
+		NamespacePrefix: "id-",
+	}
+	values := map[string]any{
+		"subdomain": "gerrit",
+		"network":   "Private",
+		"key": map[string]any{
+			"public":  "foo",
+			"private": "bar",
+		},
+		"sshPort": 22,
+	}
+	rendered, err := a.Render(release, env, values)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -178,37 +156,30 @@
 
 func TestJenkins(t *testing.T) {
 	r := NewInMemoryAppRepository(CreateAllApps())
-	a, err := r.Find("jenkins")
+	a, err := FindEnvApp(r, "jenkins")
 	if err != nil {
 		t.Fatal(err)
 	}
 	if a == nil {
 		t.Fatal("returned app is nil")
 	}
-	d := Derived{
-		Release: Release{
-			Namespace: "foo",
-		},
-		Global: Values{
-			PCloudEnvName:   "dodo",
-			Id:              "id",
-			ContactEmail:    "foo@bar.ge",
-			Domain:          "bar.ge",
-			PrivateDomain:   "p.bar.ge",
-			PublicIP:        "1.2.3.4",
-			NamespacePrefix: "id-",
-		},
-		Values: map[string]any{
-			"subdomain": "jenkins",
-			"network": map[string]any{
-				"name":             "Private",
-				"ingressClass":     "id-ingress-private",
-				"domain":           "p.bar.ge",
-				"allocatePortAddr": "http://foo.bar/api/allocate",
-			},
-		},
+	release := Release{
+		Namespace: "foo",
 	}
-	rendered, err := a.Render(d)
+	env := AppEnvConfig{
+		InfraName:       "dodo",
+		Id:              "id",
+		ContactEmail:    "foo@bar.ge",
+		Domain:          "bar.ge",
+		PrivateDomain:   "p.bar.ge",
+		PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
+		NamespacePrefix: "id-",
+	}
+	values := map[string]any{
+		"subdomain": "jenkins",
+		"network":   "Private",
+	}
+	rendered, err := a.Render(release, env, values)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/core/installer/bootstrapper.go b/core/installer/bootstrapper.go
index ad01140..6b65096 100644
--- a/core/installer/bootstrapper.go
+++ b/core/installer/bootstrapper.go
@@ -34,21 +34,33 @@
 	return Bootstrapper{cl, ns, ha, appRepo}
 }
 
-func (b Bootstrapper) Run(env EnvConfig) error {
-	if err := b.ns.Create(env.Name); err != nil {
+func (b Bootstrapper) findApp(name string) (InfraApp, error) {
+	app, err := b.appRepo.Find(name)
+	if err != nil {
+		return nil, err
+	}
+	if a, ok := app.(InfraApp); ok {
+		return a, nil
+	} else {
+		return nil, fmt.Errorf("not found")
+	}
+}
+
+func (b Bootstrapper) Run(env BootstrapConfig) error {
+	if err := b.ns.Create(env.InfraName); 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 {
+	if err := b.installLonghorn(env.InfraName, env.StorageDir, env.VolumeDefaultReplicaCount); err != nil {
 		return err
 	}
 	bootstrapJobKeys, err := NewSSHKeyPair("bootstrapper")
 	if err != nil {
 		return err
 	}
-	if err := b.installSoftServe(bootstrapJobKeys.AuthorizedKey(), env.Name, env.ServiceIPs.ConfigRepo); err != nil {
+	if err := b.installSoftServe(bootstrapJobKeys.AuthorizedKey(), env.InfraName, env.ServiceIPs.ConfigRepo); err != nil {
 		return err
 	}
 	time.Sleep(30 * time.Second)
@@ -67,7 +79,7 @@
 	if ss.AddPublicKey("admin", string(env.AdminPublicKey)); err != nil {
 		return err
 	}
-	if err := b.installFluxcd(ss, env.Name); err != nil {
+	if err := b.installFluxcd(ss, env.InfraName); err != nil {
 		return err
 	}
 	fmt.Println("Fluxcd installed")
@@ -80,35 +92,43 @@
 	if err != nil {
 		return err
 	}
+	mgr, err := NewInfraAppManager(repoIO, b.ns)
+	if err != nil {
+		return err
+	}
 	fmt.Println("Configuring main repo")
 	if err := configureMainRepo(repoIO, env); err != nil {
 		return err
 	}
 	fmt.Println("Installing infrastructure services")
-	if err := b.installInfrastructureServices(repoIO, env); err != nil {
+	if err := b.installInfrastructureServices(mgr, env); err != nil {
+		return err
+	}
+	fmt.Println("Installing public ingress")
+	if err := b.installIngressPublic(mgr, ss, env); err != nil {
 		return err
 	}
 	fmt.Println("Installing DNS Zone Manager")
-	if err := b.installDNSZoneManager(repoIO, env); err != nil {
+	if err := b.installDNSZoneManager(mgr, env); err != nil {
 		return err
 	}
 	fmt.Println("Installing Fluxcd Reconciler")
-	if err := b.installFluxcdReconciler(repoIO, ss, env); err != nil {
+	if err := b.installFluxcdReconciler(mgr, ss, env); err != nil {
 		return err
 	}
 	fmt.Println("Installing env manager")
-	if err := b.installEnvManager(repoIO, ss, env); err != nil {
+	if err := b.installEnvManager(mgr, ss, env); err != nil {
 		return err
 	}
 	fmt.Println("Installing Ory Hydra Maester")
-	if err := b.installOryHydraMaester(repoIO, env); err != nil {
+	if err := b.installOryHydraMaester(mgr, env); err != nil {
 		return err
 	}
 	fmt.Println("Environment ready to use")
 	return nil
 }
 
-func (b Bootstrapper) installMetallb(env EnvConfig) error {
+func (b Bootstrapper) installMetallb(env BootstrapConfig) error {
 	if err := b.installMetallbNamespace(env); err != nil {
 		return err
 	}
@@ -127,9 +147,9 @@
 	return nil
 }
 
-func (b Bootstrapper) installMetallbNamespace(env EnvConfig) error {
+func (b Bootstrapper) installMetallbNamespace(env BootstrapConfig) error {
 	fmt.Println("Installing metallb namespace")
-	config, err := b.ha.New(env.Name)
+	config, err := b.ha.New(env.InfraName)
 	if err != nil {
 		return err
 	}
@@ -146,7 +166,7 @@
 		},
 	}
 	installer := action.NewInstall(config)
-	installer.Namespace = env.Name
+	installer.Namespace = env.InfraName
 	installer.ReleaseName = "metallb-ns"
 	installer.Wait = true
 	installer.WaitForJobs = true
@@ -395,26 +415,21 @@
 	return nil
 }
 
-func (b Bootstrapper) installInfrastructureServices(repo RepoIO, env EnvConfig) error {
+func (b Bootstrapper) installInfrastructureServices(mgr *InfraAppManager, env BootstrapConfig) error {
 	install := func(name string) error {
 		fmt.Printf("Installing infrastructure service %s\n", name)
-		app, err := b.appRepo.Find(name)
+		app, err := b.findApp(name)
 		if err != nil {
 			return err
 		}
-		namespace := fmt.Sprintf("%s-%s", env.Name, app.Namespace())
-		derived := Derived{
-			Global: Values{
-				PCloudEnvName: env.Name,
-			},
-		}
-		return InstallApp(repo, b.ns, app, filepath.Join("/infrastructure", app.Name()), namespace, nil, derived)
+		namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
+		appDir := filepath.Join("/infrastructure", app.Name())
+		return mgr.Install(app, appDir, namespace, map[string]any{})
 	}
 	appsToInstall := []string{
 		"resource-renderer-controller",
 		"headscale-controller",
 		"csi-driver-smb",
-		"ingress-public",
 		"cert-manager",
 	}
 	for _, name := range appsToInstall {
@@ -425,9 +440,18 @@
 	return nil
 }
 
-func configureMainRepo(repo RepoIO, env EnvConfig) error {
+func configureMainRepo(repo RepoIO, bootstrap BootstrapConfig) error {
 	return repo.Atomic(func(r RepoFS) (string, error) {
-		if err := WriteYaml(r, "config.yaml", env); err != nil {
+		if err := WriteYaml(r, "bootstrap-config.yaml", bootstrap); err != nil {
+			return "", err
+		}
+		infra := InfraConfig{
+			Name:                 bootstrap.InfraName,
+			PublicIP:             bootstrap.PublicIP,
+			InfraNamespacePrefix: bootstrap.NamespacePrefix,
+			InfraAdminPublicKey:  bootstrap.AdminPublicKey,
+		}
+		if err := WriteYaml(r, "config.yaml", infra); err != nil {
 			return "", err
 		}
 		if err := WriteYaml(r, "env-cidrs.yaml", EnvCIDRs{}); err != nil {
@@ -435,7 +459,7 @@
 		}
 		kust := NewKustomization()
 		kust.AddResources(
-			fmt.Sprintf("%s-flux", env.Name),
+			fmt.Sprintf("%s-flux", bootstrap.InfraName),
 			"infrastructure",
 			"environments",
 		)
@@ -459,7 +483,7 @@
   url: https://github.com/giolekva/pcloud
   ref:
     branch: main
-`, env.Name)))
+`, bootstrap.InfraName)))
 			if err != nil {
 				return "", err
 			}
@@ -476,89 +500,94 @@
 	})
 }
 
-func (b Bootstrapper) installEnvManager(repo RepoIO, ss *soft.Client, env EnvConfig) error {
+func (b Bootstrapper) installEnvManager(mgr *InfraAppManager, ss *soft.Client, env BootstrapConfig) error {
 	keys, err := NewSSHKeyPair("env-manager")
 	if err != nil {
 		return err
 	}
-	user := fmt.Sprintf("%s-env-manager", env.Name)
+	user := fmt.Sprintf("%s-env-manager", env.InfraName)
 	if err := ss.AddUser(user, keys.AuthorizedKey()); err != nil {
 		return err
 	}
 	if err := ss.MakeUserAdmin(user); err != nil {
 		return err
 	}
-	app, err := b.appRepo.Find("env-manager")
+	app, err := b.findApp("env-manager")
 	if err != nil {
 		return err
 	}
-	namespace := fmt.Sprintf("%s-%s", env.Name, app.Namespace())
-	derived := Derived{
-		Global: Values{
-			PCloudEnvName: env.Name,
-		},
-		Values: map[string]any{
-			"repoIP":        env.ServiceIPs.ConfigRepo,
-			"repoPort":      22,
-			"repoName":      "config",
-			"sshPrivateKey": string(keys.RawPrivateKey()),
-		},
-	}
-	return InstallApp(repo, b.ns, app, filepath.Join("/infrastructure", app.Name()), namespace, derived.Values, derived)
+	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
+	appDir := filepath.Join("/infrastructure", app.Name())
+	return mgr.Install(app, appDir, namespace, map[string]any{
+		"repoIP":        env.ServiceIPs.ConfigRepo,
+		"repoPort":      22,
+		"repoName":      "config",
+		"sshPrivateKey": string(keys.RawPrivateKey()),
+	})
 }
 
-func (b Bootstrapper) installOryHydraMaester(repo RepoIO, env EnvConfig) error {
-	app, err := b.appRepo.Find("hydra-maester")
+func (b Bootstrapper) installIngressPublic(mgr *InfraAppManager, ss *soft.Client, env BootstrapConfig) error {
+	keys, err := NewSSHKeyPair("port-allocator")
 	if err != nil {
 		return err
 	}
-	namespace := fmt.Sprintf("%s-%s", env.Name, app.Namespace())
-	derived := Derived{
-		Global: Values{
-			PCloudEnvName: env.Name,
-		},
+	user := fmt.Sprintf("%s-port-allocator", env.InfraName)
+	if err := ss.AddUser(user, keys.AuthorizedKey()); err != nil {
+		return err
 	}
-	return InstallApp(repo, b.ns, app, filepath.Join("/infrastructure", app.Name()), namespace, nil, derived)
+	if err := ss.AddReadWriteCollaborator("config", user); err != nil {
+		return err
+	}
+	app, err := b.findApp("ingress-public")
+	if err != nil {
+		return err
+	}
+	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
+	appDir := filepath.Join("/infrastructure", app.Name())
+	return mgr.Install(app, appDir, namespace, map[string]any{
+		"sshPrivateKey": string(keys.RawPrivateKey()),
+	})
 }
 
-func (b Bootstrapper) installDNSZoneManager(repo RepoIO, env EnvConfig) error {
+func (b Bootstrapper) installOryHydraMaester(mgr *InfraAppManager, env BootstrapConfig) error {
+	app, err := b.findApp("hydra-maester")
+	if err != nil {
+		return err
+	}
+	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
+	appDir := filepath.Join("/infrastructure", app.Name())
+	return mgr.Install(app, appDir, namespace, map[string]any{})
+}
+
+func (b Bootstrapper) installDNSZoneManager(mgr *InfraAppManager, env BootstrapConfig) error {
 	const (
 		volumeClaimName = "dns-zone-configs"
 		volumeMountPath = "/etc/pcloud/dns-zone-configs"
 	)
-	app, err := b.appRepo.Find("dns-zone-manager")
+	app, err := b.findApp("dns-zone-manager")
 	if err != nil {
 		return err
 	}
-	namespace := fmt.Sprintf("%s-%s", env.Name, app.Namespace())
-	derived := Derived{
-		Global: Values{
-			PCloudEnvName: env.Name,
+	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
+	appDir := filepath.Join("/infrastructure", app.Name())
+	return mgr.Install(app, appDir, namespace, map[string]any{
+		"volume": map[string]any{
+			"claimName": volumeClaimName,
+			"mountPath": volumeMountPath,
+			"size":      "1Gi",
 		},
-		Values: map[string]any{
-			"volume": map[string]any{
-				"claimName": volumeClaimName,
-				"mountPath": volumeMountPath,
-				"size":      "1Gi",
-			},
-			"apiConfigMapName": dnsAPIConfigMapName,
-		},
-	}
-	return InstallApp(repo, b.ns, app, filepath.Join("/infrastructure", app.Name()), namespace, derived.Values, derived)
+		"apiConfigMapName": dnsAPIConfigMapName,
+	})
 }
 
-func (b Bootstrapper) installFluxcdReconciler(repo RepoIO, ss *soft.Client, env EnvConfig) error {
-	app, err := b.appRepo.Find("fluxcd-reconciler")
+func (b Bootstrapper) installFluxcdReconciler(mgr *InfraAppManager, ss *soft.Client, env BootstrapConfig) error {
+	app, err := b.findApp("fluxcd-reconciler")
 	if err != nil {
 		return err
 	}
-	namespace := fmt.Sprintf("%s-%s", env.Name, app.Namespace())
-	derived := Derived{
-		Global: Values{
-			PCloudEnvName: env.Name,
-		},
-	}
-	return InstallApp(repo, b.ns, app, filepath.Join("/infrastructure", app.Name()), namespace, nil, derived)
+	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
+	appDir := filepath.Join("/infrastructure", app.Name())
+	return mgr.Install(app, appDir, namespace, map[string]any{})
 }
 
 type HelmActionConfigFactory interface {
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index 621ad87..b6c083c 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -84,7 +84,7 @@
 	if err != nil {
 		return err
 	}
-	config, err := m.Config()
+	env, err := m.Config()
 	if err != nil {
 		return err
 	}
@@ -110,7 +110,7 @@
 		r,
 		tasks.NewFluxcdReconciler( // TODO(gio): make reconciler address a flag
 			"http://fluxcd-reconciler.dodo-fluxcd-reconciler.svc.cluster.local",
-			config.Values.Id,
+			env.Id,
 		),
 	)
 	return s.Start()
diff --git a/core/installer/cmd/bootstrap.go b/core/installer/cmd/bootstrap.go
index e6385b5..dc85470 100644
--- a/core/installer/cmd/bootstrap.go
+++ b/core/installer/cmd/bootstrap.go
@@ -3,8 +3,10 @@
 import (
 	_ "embed"
 	"fmt"
+	"net"
 	"net/netip"
 	"os"
+	"strings"
 
 	"github.com/spf13/cobra"
 
@@ -34,7 +36,7 @@
 		"",
 	)
 	cmd.Flags().StringVar(
-		&bootstrapFlags.envName,
+		&bootstrapFlags.publicIP,
 		"public-ip",
 		"",
 		"",
@@ -93,9 +95,10 @@
 	if err != nil {
 		return err
 	}
-	envConfig := installer.EnvConfig{
-		Name:                      bootstrapFlags.envName,
-		PublicIP:                  bootstrapFlags.publicIP,
+	publicIPs, err := parseIPs(bootstrapFlags.publicIP)
+	envConfig := installer.BootstrapConfig{
+		InfraName:                 bootstrapFlags.envName,
+		PublicIP:                  publicIPs,
 		NamespacePrefix:           fmt.Sprintf("%s-", bootstrapFlags.envName),
 		StorageDir:                bootstrapFlags.storageDir,
 		VolumeDefaultReplicaCount: bootstrapFlags.volumeDefaultReplicaCount,
@@ -130,3 +133,15 @@
 		To:            t,
 	}, nil
 }
+
+func parseIPs(ip string) ([]net.IP, error) {
+	ret := make([]net.IP, 0)
+	for _, i := range strings.Split(ip, ",") {
+		ip := net.ParseIP(i)
+		if ip == nil {
+			return nil, fmt.Errorf("invalid ip: %s", i)
+		}
+		ret = append(ret, ip)
+	}
+	return ret, nil
+}
diff --git a/core/installer/config.go b/core/installer/config.go
index 5269512..99ae358 100644
--- a/core/installer/config.go
+++ b/core/installer/config.go
@@ -12,9 +12,9 @@
 	To            netip.Addr `json:"to"`
 }
 
-type EnvConfig struct {
-	Name                      string        `json:"name"`
-	PublicIP                  string        `json:"publicIP"`
+type BootstrapConfig struct {
+	InfraName                 string        `json:"name"`
+	PublicIP                  []net.IP      `json:"publicIP"`
 	NamespacePrefix           string        `json:"namespacePrefix"`
 	StorageDir                string        `json:"storageDir"`
 	VolumeDefaultReplicaCount int           `json:"volumeDefaultReplicaCount"`
@@ -28,27 +28,3 @@
 }
 
 type EnvCIDRs []EnvCIDR
-
-type Config struct {
-	Values Values `json:"input"` // TODO(gio): rename
-}
-
-type Values struct {
-	PCloudEnvName   string `json:"pcloudEnvName,omitempty"`
-	Id              string `json:"id,omitempty"`
-	ContactEmail    string `json:"contactEmail,omitempty"`
-	Domain          string `json:"domain,omitempty"`
-	PrivateDomain   string `json:"privateDomain,omitempty"`
-	PublicIP        string `json:"publicIP,omitempty"`
-	NamespacePrefix string `json:"namespacePrefix,omitempty"`
-	// GandiAPIToken   string `json:"gandiAPIToken,omitempty"`
-	// LighthouseAuthUIIP       string `json:"lighthouseAuthUIIP,omitempty"`
-	// LighthouseMainIP         string `json:"lighthouseMainIP,omitempty"`
-	// LighthouseMainPort       string `json:"lighthouseMainPort,omitempty"`
-	// MXHostname               string `json:"mxHostname,omitempty"`
-	// MailGatewayAddress       string `json:"mailGatewayAddress,omitempty"`
-	// MatrixOAuth2ClientSecret string `json:"matrixOAuth2ClientSecret,omitempty"`
-	// MatrixStorageSize        string `json:"matrixStorageSize,omitempty"`
-	// PiholeOAuth2ClientSecret string `json:"piholeOAuth2ClientSecret,omitempty"`
-	// PiholeOAuth2CookieSecret string `json:"piholeOAuth2CookieSecret,omitempty"`
-}
diff --git a/core/installer/derived.go b/core/installer/derived.go
index 813939e..baa453e 100644
--- a/core/installer/derived.go
+++ b/core/installer/derived.go
@@ -5,15 +5,10 @@
 )
 
 type Release struct {
-	Namespace string `json:"namespace"`
-	RepoAddr  string `json:"repoAddr"`
-	AppDir    string `json:"appDir"`
-}
-
-type Derived struct {
-	Release Release        `json:"release"`
-	Global  Values         `json:"global"`
-	Values  map[string]any `json:"input"` // TODO(gio): rename to input
+	AppInstanceId string `json:"appInstanceId"`
+	Namespace     string `json:"namespace"`
+	RepoAddr      string `json:"repoAddr"`
+	AppDir        string `json:"appDir"`
 }
 
 type Network struct {
@@ -24,17 +19,19 @@
 	AllocatePortAddr  string `json:"allocatePortAddr,omitempty"`
 }
 
-type AppConfig struct {
+type AppInstanceConfig struct {
 	Id      string         `json:"id"`
 	AppId   string         `json:"appId"`
-	Config  map[string]any `json:"config"`
-	Derived Derived        `json:"derived"`
+	Env     AppEnvConfig   `json:"env"`
+	Release Release        `json:"release"`
+	Values  map[string]any `json:"values"`
+	Input   map[string]any `json:"input"`
 }
 
-func (a AppConfig) Input(schema Schema) map[string]any {
-	ret, err := derivedToConfig(a.Derived.Values, schema)
+func (a AppInstanceConfig) InputToValues(schema Schema) map[string]any {
+	ret, err := derivedToConfig(a.Input, schema)
 	if err != nil {
-		panic(err) // TODO(gio): handle
+		panic(err)
 	}
 	return ret
 }
diff --git a/core/installer/repoio.go b/core/installer/repoio.go
index ffb4078..c5e80c6 100644
--- a/core/installer/repoio.go
+++ b/core/installer/repoio.go
@@ -102,10 +102,18 @@
 	if err != nil {
 		return nil
 	}
-	return wt.Pull(&git.PullOptions{
+	err = wt.Pull(&git.PullOptions{
 		Auth:  auth(r.signer),
 		Force: true,
 	})
+	if err == nil {
+		return nil
+	}
+	if errors.Is(err, git.NoErrAlreadyUpToDate) {
+		return nil
+	}
+	// TODO(gio): check `remote repository is empty`
+	return nil
 }
 
 func (r *repoIO) CommitAndPush(message string) error {
diff --git a/core/installer/tasks/env.go b/core/installer/tasks/env.go
index 7eaf0da..68fbe57 100644
--- a/core/installer/tasks/env.go
+++ b/core/installer/tasks/env.go
@@ -12,16 +12,17 @@
 )
 
 type state struct {
-	infoListener EnvInfoListener
-	publicIPs    []net.IP
-	nsCreator    installer.NamespaceCreator
-	repo         installer.RepoIO
-	ssAdminKeys  *keygen.KeyPair
-	ssClient     *soft.Client
-	fluxUserName string
-	keys         *keygen.KeyPair
-	appManager   *installer.AppManager
-	appsRepo     installer.AppRepository
+	infoListener    EnvInfoListener
+	publicIPs       []net.IP
+	nsCreator       installer.NamespaceCreator
+	repo            installer.RepoIO
+	ssAdminKeys     *keygen.KeyPair
+	ssClient        *soft.Client
+	fluxUserName    string
+	keys            *keygen.KeyPair
+	appManager      *installer.AppManager
+	appsRepo        installer.AppRepository
+	infraAppManager *installer.InfraAppManager
 }
 
 type Env struct {
@@ -46,13 +47,15 @@
 	startIP net.IP,
 	nsCreator installer.NamespaceCreator,
 	repo installer.RepoIO,
+	mgr *installer.InfraAppManager,
 	infoListener EnvInfoListener,
 ) (Task, DNSZoneRef) {
 	st := state{
-		infoListener: infoListener,
-		publicIPs:    publicIPs,
-		nsCreator:    nsCreator,
-		repo:         repo,
+		infoListener:    infoListener,
+		publicIPs:       publicIPs,
+		nsCreator:       nsCreator,
+		repo:            repo,
+		infraAppManager: mgr,
 	}
 	t := newSequentialParentTask(
 		"Create env",
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index d718428..fb3047d 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -64,16 +64,14 @@
 		r.Atomic(func(r installer.RepoFS) (string, error) {
 			{
 				// TODO(giolekva): private domain can be configurable as well
-				config := installer.Config{
-					Values: installer.Values{
-						PCloudEnvName:   env.PCloudEnvName,
-						Id:              env.Name,
-						ContactEmail:    env.ContactEmail,
-						Domain:          env.Domain,
-						PrivateDomain:   fmt.Sprintf("p.%s", env.Domain),
-						PublicIP:        st.publicIPs[0].String(),
-						NamespacePrefix: fmt.Sprintf("%s-", env.Name),
-					},
+				config := installer.AppEnvConfig{
+					Id:              env.Name,
+					InfraName:       env.PCloudEnvName,
+					Domain:          env.Domain,
+					PrivateDomain:   fmt.Sprintf("p.%s", env.Domain),
+					ContactEmail:    env.ContactEmail,
+					PublicIP:        st.publicIPs,
+					NamespacePrefix: fmt.Sprintf("%s-", env.Name),
 				}
 				if err := installer.WriteYaml(r, "config.yaml", config); err != nil {
 					return "", err
@@ -94,7 +92,7 @@
   interval: 1m0s
   url: https://github.com/giolekva/pcloud
   ref:
-    branch: ingress-port-allocator
+    branch: main
 `, env.Name)
 			if err != nil {
 				return "", err
@@ -166,14 +164,15 @@
 		{
 			ingressPrivateIP := startAddr
 			headscaleIP := ingressPrivateIP.Next()
-			app, err := st.appsRepo.Find("metallb-ipaddresspool")
+			app, err := installer.FindEnvApp(st.appsRepo, "metallb-ipaddresspool")
 			if err != nil {
 				return err
 			}
 			{
-				appDir := fmt.Sprintf("/apps/%s-ingress-private", app.Name())
+				instanceId := fmt.Sprintf("%s-ingress-private", app.Name())
+				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
-				if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 					"name":       fmt.Sprintf("%s-ingress-private", env.Name),
 					"from":       ingressPrivateIP.String(),
 					"to":         ingressPrivateIP.String(),
@@ -184,9 +183,10 @@
 				}
 			}
 			{
-				appDir := fmt.Sprintf("/apps/%s-headscale", app.Name())
+				instanceId := fmt.Sprintf("%s-headscale", app.Name())
+				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
-				if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 					"name":       fmt.Sprintf("%s-headscale", env.Name),
 					"from":       headscaleIP.String(),
 					"to":         headscaleIP.String(),
@@ -197,9 +197,10 @@
 				}
 			}
 			{
-				appDir := fmt.Sprintf("/apps/%s", app.Name())
+				instanceId := app.Name()
+				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-				if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 					"name":       env.Name,
 					"from":       fromIP.String(),
 					"to":         toIP.String(),
@@ -222,13 +223,14 @@
 			if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil {
 				return err
 			}
-			app, err := st.appsRepo.Find("private-network")
+			app, err := installer.FindEnvApp(st.appsRepo, "private-network")
 			if err != nil {
 				return err
 			}
-			appDir := fmt.Sprintf("/apps/%s", app.Name())
+			instanceId := app.Name()
+			appDir := fmt.Sprintf("/apps/%s", instanceId)
 			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-			if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+			if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 				"privateNetwork": map[string]any{
 					"hostname": "private-network-proxy",
 					"username": "private-network-proxy",
@@ -246,25 +248,27 @@
 
 func SetupCertificateIssuers(env Env, st *state) Task {
 	pub := newLeafTask(fmt.Sprintf("Public %s", env.Domain), func() error {
-		app, err := st.appsRepo.Find("certificate-issuer-public")
+		app, err := installer.FindEnvApp(st.appsRepo, "certificate-issuer-public")
 		if err != nil {
 			return err
 		}
-		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		instanceId := app.Name()
+		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, appDir, namespace, map[string]any{}); err != nil {
+		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{}); err != nil {
 			return err
 		}
 		return nil
 	})
 	priv := newLeafTask(fmt.Sprintf("Private p.%s", env.Domain), func() error {
-		app, err := st.appsRepo.Find("certificate-issuer-private")
+		app, err := installer.FindEnvApp(st.appsRepo, "certificate-issuer-private")
 		if err != nil {
 			return err
 		}
-		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		instanceId := app.Name()
+		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"apiConfigMap": map[string]any{
 				"name":      "api-config", // TODO(gio): take from global pcloud config
 				"namespace": fmt.Sprintf("%s-dns-zone-manager", env.PCloudEnvName),
@@ -279,13 +283,14 @@
 
 func SetupAuth(env Env, st *state) Task {
 	t := newLeafTask("Setup", func() error {
-		app, err := st.appsRepo.Find("core-auth")
+		app, err := installer.FindEnvApp(st.appsRepo, "core-auth")
 		if err != nil {
 			return err
 		}
-		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		instanceId := app.Name()
+		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"subdomain": "test", // TODO(giolekva): make core-auth chart actually use this
 		}); err != nil {
 			return err
@@ -302,13 +307,14 @@
 
 func SetupGroupMemberships(env Env, st *state) Task {
 	t := newLeafTask("Setup", func() error {
-		app, err := st.appsRepo.Find("memberships")
+		app, err := installer.FindEnvApp(st.appsRepo, "memberships")
 		if err != nil {
 			return err
 		}
-		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		instanceId := app.Name()
+		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"authGroups": strings.Join(initGroups, ","),
 		}); err != nil {
 			return err
@@ -325,13 +331,14 @@
 
 func SetupHeadscale(env Env, startIP net.IP, st *state) Task {
 	t := newLeafTask("Setup", func() error {
-		app, err := st.appsRepo.Find("headscale")
+		app, err := installer.FindEnvApp(st.appsRepo, "headscale")
 		if err != nil {
 			return err
 		}
-		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		instanceId := app.Name()
+		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"subdomain": "headscale",
 			"ipSubnet":  fmt.Sprintf("%s/24", startIP),
 		}); err != nil {
@@ -360,13 +367,14 @@
 		if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil {
 			return err
 		}
-		app, err := st.appsRepo.Find("welcome")
+		app, err := installer.FindEnvApp(st.appsRepo, "welcome")
 		if err != nil {
 			return err
 		}
-		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		instanceId := app.Name()
+		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"repoAddr":      st.ssClient.GetRepoAddress("config"),
 			"sshPrivateKey": string(keys.RawPrivateKey()),
 		}); err != nil {
@@ -395,13 +403,14 @@
 		if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil {
 			return err
 		}
-		app, err := st.appsRepo.Find("app-manager") // TODO(giolekva): configure
+		app, err := installer.FindEnvApp(st.appsRepo, "app-manager") // TODO(giolekva): configure
 		if err != nil {
 			return err
 		}
-		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		instanceId := app.Name()
+		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"repoAddr":      st.ssClient.GetRepoAddress("config"),
 			"sshPrivateKey": string(keys.RawPrivateKey()),
 			"authGroups":    strings.Join(initGroups, ","),
diff --git a/core/installer/tasks/init.go b/core/installer/tasks/init.go
index 4ed7176..70b7939 100644
--- a/core/installer/tasks/init.go
+++ b/core/installer/tasks/init.go
@@ -38,31 +38,25 @@
 func NewCreateConfigRepoTask(env Env, st *state) Task {
 	t := newLeafTask("Install Git server", func() error {
 		appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
-		ssApp, err := appsRepo.Find("config-repo")
+		app, err := installer.FindInfraApp(appsRepo, "config-repo")
 		if err != nil {
 			return err
 		}
-		ssAdminKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-admin-keys", env.Name))
+		adminKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-admin-keys", env.Name))
 		if err != nil {
 			return err
 		}
-		st.ssAdminKeys = ssAdminKeys
-		ssKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-keys", env.Name))
+		st.ssAdminKeys = adminKeys
+		keys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-keys", env.Name))
 		if err != nil {
 			return err
 		}
-		derived := installer.Derived{
-			Global: installer.Values{
-				Id:            env.Name,
-				PCloudEnvName: env.PCloudEnvName,
-			},
-			Values: map[string]any{
-				"privateKey": string(ssKeys.RawPrivateKey()),
-				"publicKey":  string(ssKeys.RawAuthorizedKey()),
-				"adminKey":   string(ssAdminKeys.RawAuthorizedKey()),
-			},
-		}
-		return installer.InstallApp(st.repo, st.nsCreator, ssApp, filepath.Join("/environments", env.Name, "config-repo"), env.Name, derived.Values, derived)
+		appDir := filepath.Join("/environments", env.Name, "config-repo")
+		return st.infraAppManager.Install(app, appDir, env.Name, map[string]any{
+			"privateKey": string(keys.RawPrivateKey()),
+			"publicKey":  string(keys.RawAuthorizedKey()),
+			"adminKey":   string(adminKeys.RawAuthorizedKey()),
+		})
 	})
 	return &t
 }
diff --git a/core/installer/values-tmpl/ingress-public.cue b/core/installer/values-tmpl/ingress-public.cue
index e723342..93ce90c 100644
--- a/core/installer/values-tmpl/ingress-public.cue
+++ b/core/installer/values-tmpl/ingress-public.cue
@@ -1,4 +1,10 @@
-input: {}
+import (
+	"encoding/base64"
+)
+
+input: {
+	sshPrivateKey: string
+}
 
 name: "ingress-public"
 namespace: "ingress-public"
@@ -11,6 +17,12 @@
 		tag: "v1.8.0"
 		pullPolicy: "IfNotPresent"
 	}
+	portAllocator: {
+		repository: "giolekva"
+		name: "port-allocator"
+		tag: "latest"
+		pullPolicy: "Always"
+	}
 }
 
 charts: {
@@ -22,6 +34,14 @@
 			namespace: global.pcloudEnvName
 		}
 	}
+	portAllocator: {
+		chart: "charts/port-allocator"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.pcloudEnvName
+		}
+	}
 }
 
 helm: {
@@ -57,4 +77,17 @@
 			}
 		}
 	}
+	"port-allocator": {
+		chart: charts.portAllocator
+		values: {
+			repoAddr: release.repoAddr
+			sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
+			ingressNginxPath: "\(release.appDir)/ingress-public.yaml"
+			image: {
+				repository: images.portAllocator.fullName
+				tag: images.portAllocator.tag
+				pullPolicy: images.portAllocator.pullPolicy
+			}
+		}
+	}
 }
diff --git a/core/installer/welcome/appmanager-tmpl/app.html b/core/installer/welcome/appmanager-tmpl/app.html
index c1ae84a..8c25adf 100644
--- a/core/installer/welcome/appmanager-tmpl/app.html
+++ b/core/installer/welcome/appmanager-tmpl/app.html
@@ -75,7 +75,7 @@
 
 <form id="config-form">
     {{ if $instance }}
-      {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" ($instance.Input $schema)) }}
+      {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" ($instance.InputToValues $schema)) }}
     {{ else }}
       {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" (dict)) }}
     {{ end }}
@@ -93,7 +93,7 @@
   {{ if or (not $instance) (ne $instance.Id .Id)}}
     <details>
       <summary>{{ .Id }}</summary>
-      {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" true "Data" (.Input $schema) ) }}
+      {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" true "Data" (.InputToValues $schema)) }}
       <a href="/instance/{{ .Id }}" role="button" class="secondary">View</a>
     </details>
   {{ end }}
@@ -139,7 +139,7 @@
 
 <script>
  let readme = "";
- let config = {{ if $instance }}JSON.parse({{ toJson ($instance.Input $schema) }}){{ else }}{}{{ end }};
+ let config = {{ if $instance }}JSON.parse({{ toJson ($instance.InputToValues $schema) }}){{ else }}{}{{ end }};
 
  function setValue(name, value, config) {
   let items = name.split(".")
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
index b26a001..8a4bf34 100644
--- a/core/installer/welcome/appmanager.go
+++ b/core/installer/welcome/appmanager.go
@@ -66,11 +66,11 @@
 }
 
 type app struct {
-	Name             string                `json:"name"`
-	Icon             template.HTML         `json:"icon"`
-	ShortDescription string                `json:"shortDescription"`
-	Slug             string                `json:"slug"`
-	Instances        []installer.AppConfig `json:"instances,omitempty"`
+	Name             string                        `json:"name"`
+	Icon             template.HTML                 `json:"icon"`
+	ShortDescription string                        `json:"shortDescription"`
+	Slug             string                        `json:"slug"`
+	Instances        []installer.AppInstanceConfig `json:"instances,omitempty"`
 }
 
 func (s *AppManagerServer) handleAppRepo(c echo.Context) error {
@@ -95,25 +95,6 @@
 	if err != nil {
 		return err
 	}
-	for _, instance := range instances {
-		values, ok := instance.Config["Values"].(map[string]any)
-		if !ok {
-			return fmt.Errorf("Expected map")
-		}
-		for k, v := range values {
-			if k == "Network" {
-				n, ok := v.(map[string]any)
-				if !ok {
-					return fmt.Errorf("Expected map")
-				}
-				values["Network"], ok = n["Name"]
-				if !ok {
-					return fmt.Errorf("Missing Name")
-				}
-				break
-			}
-		}
-	}
 	return c.JSON(http.StatusOK, app{a.Name(), a.Icon(), a.Description(), a.Name(), instances})
 }
 
@@ -123,28 +104,11 @@
 	if err != nil {
 		return err
 	}
-	values, ok := instance.Config["Values"].(map[string]any)
-	if !ok {
-		return fmt.Errorf("Expected map")
-	}
-	for k, v := range values {
-		if k == "Network" {
-			n, ok := v.(map[string]any)
-			if !ok {
-				return fmt.Errorf("Expected map")
-			}
-			values["Network"], ok = n["Name"]
-			if !ok {
-				return fmt.Errorf("Missing Name")
-			}
-			break
-		}
-	}
 	a, err := s.r.Find(instance.AppId)
 	if err != nil {
 		return err
 	}
-	return c.JSON(http.StatusOK, app{a.Name(), a.Icon(), a.Description(), a.Name(), []installer.AppConfig{instance}})
+	return c.JSON(http.StatusOK, app{a.Name(), a.Icon(), a.Description(), a.Name(), []installer.AppInstanceConfig{instance}})
 }
 
 type file struct {
@@ -162,7 +126,7 @@
 	if err != nil {
 		return err
 	}
-	global, err := s.m.Config()
+	env, err := s.m.Config()
 	if err != nil {
 		return err
 	}
@@ -170,22 +134,11 @@
 	if err := json.Unmarshal(contents, &values); err != nil {
 		return err
 	}
-	if network, ok := values["network"]; ok {
-		for _, n := range installer.CreateNetworks(global) {
-			if n.Name == network { // TODO(giolekva): handle not found
-				values["network"] = n
-			}
-		}
-	}
-	all := installer.Derived{
-		Global: global.Values,
-		Values: values,
-	}
-	a, err := s.r.Find(slug)
+	a, err := installer.FindEnvApp(s.r, slug)
 	if err != nil {
 		return err
 	}
-	r, err := a.Render(all)
+	r, err := a.Render(installer.Release{}, env, values)
 	if err != nil {
 		return err
 	}
@@ -212,24 +165,25 @@
 		return err
 	}
 	log.Printf("Values: %+v\n", values)
-	a, err := s.r.Find(slug)
+	a, err := installer.FindEnvApp(s.r, slug)
 	if err != nil {
 		return err
 	}
 	log.Printf("Found application: %s\n", slug)
-	config, err := s.m.Config()
+	env, err := s.m.Config()
 	if err != nil {
 		return err
 	}
-	log.Printf("Configuration: %+v\n", config)
+	log.Printf("Configuration: %+v\n", env)
 	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
 	suffix, err := suffixGen.Generate()
 	if err != nil {
 		return err
 	}
-	appDir := fmt.Sprintf("/apps/%s%s", a.Name(), suffix)
-	namespace := fmt.Sprintf("%s%s%s", config.Values.NamespacePrefix, a.Namespace(), suffix)
-	if err := s.m.Install(a, appDir, namespace, values); err != nil {
+	instanceId := a.Name() + suffix
+	appDir := fmt.Sprintf("/apps/%s", instanceId)
+	namespace := fmt.Sprintf("%s%s%s", env.NamespacePrefix, a.Namespace(), suffix)
+	if err := s.m.Install(a, instanceId, appDir, namespace, values); err != nil {
 		log.Printf("%s\n", err.Error())
 		return err
 	}
@@ -252,7 +206,7 @@
 	if err := json.Unmarshal(contents, &values); err != nil {
 		return err
 	}
-	a, err := s.r.Find(appConfig.AppId)
+	a, err := installer.FindEnvApp(s.r, appConfig.AppId)
 	if err != nil {
 		return err
 	}
@@ -291,9 +245,9 @@
 }
 
 type appContext struct {
-	App               installer.App
-	Instance          *installer.AppConfig
-	Instances         []installer.AppConfig
+	App               installer.EnvApp
+	Instance          *installer.AppInstanceConfig
+	Instances         []installer.AppInstanceConfig
 	AvailableNetworks []installer.Network
 }
 
@@ -311,7 +265,7 @@
 		return err
 	}
 	slug := c.Param("slug")
-	a, err := s.r.Find(slug)
+	a, err := installer.FindEnvApp(s.r, slug)
 	if err != nil {
 		return err
 	}
@@ -346,7 +300,7 @@
 	if err != nil {
 		return err
 	}
-	a, err := s.r.Find(instance.AppId)
+	a, err := installer.FindEnvApp(s.r, instance.AppId)
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index 3efe330..219c67c 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -326,8 +326,13 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	var env installer.EnvConfig
-	if err := installer.ReadYaml(s.repo, "config.yaml", &env); err != nil {
+	mgr, err := installer.NewInfraAppManager(s.repo, s.nsCreator)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	var infra installer.InfraConfig
+	if err := installer.ReadYaml(s.repo, "config.yaml", &infra); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -373,20 +378,18 @@
 	}
 	t, dns := tasks.NewCreateEnvTask(
 		tasks.Env{
-			PCloudEnvName:   env.Name,
+			PCloudEnvName:   infra.Name,
 			Name:            req.Name,
 			ContactEmail:    req.ContactEmail,
 			Domain:          req.Domain,
 			AdminPublicKey:  req.AdminPublicKey,
 			NamespacePrefix: fmt.Sprintf("%s-", req.Name),
 		},
-		[]net.IP{
-			net.ParseIP("135.181.48.180"),
-			net.ParseIP("65.108.39.172"),
-		},
+		infra.PublicIP,
 		startIP,
 		s.nsCreator,
 		s.repo,
+		mgr,
 		infoUpdater,
 	)
 	s.tasks[key] = t
diff --git a/core/installer/welcome/welcome.go b/core/installer/welcome/welcome.go
index 697fd07..a820586 100644
--- a/core/installer/welcome/welcome.go
+++ b/core/installer/welcome/welcome.go
@@ -210,21 +210,22 @@
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}
-		config, err := appManager.Config()
+		env, err := appManager.Config()
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}
 		appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 		{
-			app, err := appsRepo.Find("headscale-user")
+			app, err := installer.FindEnvApp(appsRepo, "headscale-user")
 			if err != nil {
 				http.Error(w, err.Error(), http.StatusInternalServerError)
 				return
 			}
-			appDir := fmt.Sprintf("/apps/%s-%s", app.Name(), req.Username)
-			namespace := fmt.Sprintf("%s%s", config.Values.NamespacePrefix, app.Namespace())
-			if err := appManager.Install(app, appDir, namespace, map[string]any{
+			instanceId := fmt.Sprintf("%s-%s", app.Name(), req.Username)
+			appDir := fmt.Sprintf("/apps/%s", instanceId)
+			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+			if err := appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 				"username": req.Username,
 				"preAuthKey": map[string]any{
 					"enabled": false,