installer: helper leaf/parent tasks
diff --git a/core/installer/tasks/activate.go b/core/installer/tasks/activate.go
index f8b8018..f02e7a8 100644
--- a/core/installer/tasks/activate.go
+++ b/core/installer/tasks/activate.go
@@ -8,10 +8,6 @@
 	"path"
 	"strings"
 	"text/template"
-
-	"github.com/charmbracelet/keygen"
-
-	"github.com/giolekva/pcloud/core/installer"
 )
 
 //go:embed env-tmpl
@@ -24,76 +20,51 @@
 }
 
 func NewActivateEnvTask(env Env, st *state) Task {
-	return &activateEnvTask{
-		basicTask: basicTask{
-			title: fmt.Sprintf("Activate %s environment", env.Name),
-		},
-		env: env,
-		st:  st,
-	}
-}
-
-func (t *activateEnvTask) Start() {
-	ssPublicKeys, err := t.st.ssClient.GetPublicKeys()
-	if err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	if err := t.addNewEnv(
-		t.st.repo,
-		strings.Split(t.st.ssClient.Addr, ":")[0],
-		t.st.keys,
-		ssPublicKeys,
-	); err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	t.callDoneListeners(nil)
-}
-
-func (t *activateEnvTask) addNewEnv(
-	repoIO installer.RepoIO,
-	repoHost string,
-	keys *keygen.KeyPair,
-	configRepoPublicKeys []string,
-) error {
-	kust, err := repoIO.ReadKustomization("environments/kustomization.yaml")
-	if err != nil {
-		return err
-	}
-	kust.AddResources(t.env.Name)
-	tmpls, err := template.ParseFS(filesTmpls, "env-tmpl/*.yaml")
-	if err != nil {
-		return err
-	}
-	var knownHosts bytes.Buffer
-	for _, key := range configRepoPublicKeys {
-		fmt.Fprintf(&knownHosts, "%s %s\n", repoHost, key)
-	}
-	for _, tmpl := range tmpls.Templates() {
-		dstPath := path.Join("environments", t.env.Name, tmpl.Name())
-		dst, err := repoIO.Writer(dstPath)
+	t := newLeafTask(fmt.Sprintf("Activate %s environment", env.Name), func() error {
+		ssPublicKeys, err := st.ssClient.GetPublicKeys()
 		if err != nil {
 			return err
 		}
-		defer dst.Close()
-
-		if err := tmpl.Execute(dst, map[string]string{
-			"Name":       t.env.Name,
-			"PrivateKey": base64.StdEncoding.EncodeToString(keys.RawPrivateKey()),
-			"PublicKey":  base64.StdEncoding.EncodeToString(keys.RawAuthorizedKey()),
-			"RepoHost":   repoHost,
-			"RepoName":   "config",
-			"KnownHosts": base64.StdEncoding.EncodeToString(knownHosts.Bytes()),
-		}); err != nil {
+		repoHost := strings.Split(st.ssClient.Addr, ":")[0]
+		kust, err := st.repo.ReadKustomization("environments/kustomization.yaml")
+		if err != nil {
 			return err
 		}
-	}
-	if err := repoIO.WriteKustomization("environments/kustomization.yaml", *kust); err != nil {
-		return err
-	}
-	if err := repoIO.CommitAndPush(fmt.Sprintf("%s: initialize environment", t.env.Name)); err != nil {
-		return err
-	}
-	return nil
+		kust.AddResources(env.Name)
+		tmpls, err := template.ParseFS(filesTmpls, "env-tmpl/*.yaml")
+		if err != nil {
+			return err
+		}
+		var knownHosts bytes.Buffer
+		for _, key := range ssPublicKeys {
+			fmt.Fprintf(&knownHosts, "%s %s\n", repoHost, key)
+		}
+		for _, tmpl := range tmpls.Templates() {
+			dstPath := path.Join("environments", env.Name, tmpl.Name())
+			dst, err := st.repo.Writer(dstPath)
+			if err != nil {
+				return err
+			}
+			defer dst.Close()
+
+			if err := tmpl.Execute(dst, map[string]string{
+				"Name":       env.Name,
+				"PrivateKey": base64.StdEncoding.EncodeToString(st.keys.RawPrivateKey()),
+				"PublicKey":  base64.StdEncoding.EncodeToString(st.keys.RawAuthorizedKey()),
+				"RepoHost":   repoHost,
+				"RepoName":   "config",
+				"KnownHosts": base64.StdEncoding.EncodeToString(knownHosts.Bytes()),
+			}); err != nil {
+				return err
+			}
+		}
+		if err := st.repo.WriteKustomization("environments/kustomization.yaml", *kust); err != nil {
+			return err
+		}
+		if err := st.repo.CommitAndPush(fmt.Sprintf("%s: initialize environment", env.Name)); err != nil {
+			return err
+		}
+		return nil
+	})
+	return &t
 }
diff --git a/core/installer/tasks/dns.go b/core/installer/tasks/dns.go
index 60a8a9d..08cb863 100644
--- a/core/installer/tasks/dns.go
+++ b/core/installer/tasks/dns.go
@@ -2,7 +2,6 @@
 
 import (
 	"context"
-	"fmt"
 	"net"
 	"text/template"
 	"time"
@@ -12,54 +11,32 @@
 	"github.com/giolekva/pcloud/core/installer"
 )
 
-type dnsResolver struct {
-	basicTask
-	name     string
-	expected []net.IP
-	ctx      context.Context
-	env      Env
-	st       *state
-}
+type Check func(ch Check) error
 
 func NewDNSResolverTask(
 	name string,
 	expected []net.IP,
-	ctx context.Context,
 	env Env,
 	st *state,
 ) Task {
-	return &dnsResolver{
-		basicTask: basicTask{
-			title: "Configure DNS",
-		},
-		name:     name,
-		expected: expected,
-		ctx:      ctx,
-		env:      env,
-		st:       st,
-	}
-}
-
-func (t *dnsResolver) Start() {
-	repo, err := t.st.ssClient.GetRepo("config")
-	if err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	r := installer.NewRepoIO(repo, t.st.ssClient.Signer)
-	{
-		key, err := newDNSSecKey(t.env.Domain)
+	ctx := context.TODO()
+	t := newLeafTask("Configure DNS", func() error {
+		repo, err := st.ssClient.GetRepo("config")
 		if err != nil {
-			t.callDoneListeners(err)
-			return
+			return err
 		}
-		out, err := r.Writer("dns-zone.yaml")
-		if err != nil {
-			t.callDoneListeners(err)
-			return
-		}
-		defer out.Close()
-		dnsZoneTmpl, err := template.New("config").Funcs(sprig.TxtFuncMap()).Parse(`
+		r := installer.NewRepoIO(repo, st.ssClient.Signer)
+		{
+			key, err := newDNSSecKey(env.Domain)
+			if err != nil {
+				return err
+			}
+			out, err := r.Writer("dns-zone.yaml")
+			if err != nil {
+				return err
+			}
+			defer out.Close()
+			dnsZoneTmpl, err := template.New("config").Funcs(sprig.TxtFuncMap()).Parse(`
 apiVersion: dodo.cloud.dodo.cloud/v1
 kind: DNSZone
 metadata:
@@ -92,58 +69,53 @@
   private: {{ .dnssec.Private | toString | b64enc }}
   ds: {{ .dnssec.DS | toString | b64enc }}
 `)
-		if err != nil {
-			t.callDoneListeners(err)
-			return
+			if err != nil {
+				return err
+			}
+			if err := dnsZoneTmpl.Execute(out, map[string]any{
+				"namespace": env.Name,
+				"zone":      env.Domain,
+				"dnssec":    key,
+				"publicIPs": st.publicIPs,
+			}); err != nil {
+				return err
+			}
+			rootKust := installer.NewKustomization()
+			rootKust.AddResources("dns-zone.yaml")
+			if err := r.WriteKustomization("kustomization.yaml", rootKust); err != nil {
+				return err
+			}
+			r.CommitAndPush("configure dns zone")
 		}
-		if err := dnsZoneTmpl.Execute(out, map[string]any{
-			"namespace": t.env.Name,
-			"zone":      t.env.Domain,
-			"dnssec":    key,
-			"publicIPs": t.st.publicIPs,
-		}); err != nil {
-			t.callDoneListeners(err)
-			return
-		}
-		rootKust := installer.NewKustomization()
-		rootKust.AddResources("dns-zone.yaml")
-		if err := r.WriteKustomization("kustomization.yaml", rootKust); err != nil {
-			t.callDoneListeners(err)
-			return
-		}
-		r.CommitAndPush("configure dns zone")
-	}
 
-	gotExpectedIPs := func(actual []net.IP) bool {
-		for _, a := range actual {
-			found := false
-			for _, e := range t.expected {
-				if a.Equal(e) {
-					found = true
-					break
+		gotExpectedIPs := func(actual []net.IP) bool {
+			for _, a := range actual {
+				found := false
+				for _, e := range expected {
+					if a.Equal(e) {
+						found = true
+						break
+					}
+				}
+				if !found {
+					return false
 				}
 			}
-			if !found {
-				return false
+			return true
+		}
+		check := func(check Check) error {
+			addrs, err := net.LookupIP(name)
+			if err == nil && gotExpectedIPs(addrs) {
+				return err
+			}
+			select {
+			case <-ctx.Done():
+				return nil
+			case <-time.After(5 * time.Second):
+				return check(check)
 			}
 		}
-		return true
-	}
-	check := func(check Check) {
-		addrs, err := net.LookupIP(t.name)
-		if err == nil && gotExpectedIPs(addrs) {
-			t.callDoneListeners(nil)
-			return
-		}
-		select {
-		case <-t.ctx.Done():
-			t.callDoneListeners(fmt.Errorf("deadline exceeded"))
-			return
-		case <-time.After(5 * time.Second):
-			check(check)
-		}
-	}
-	check(check)
+		return check(check)
+	})
+	return &t
 }
-
-type Check func(ch Check)
diff --git a/core/installer/tasks/dns_test.go b/core/installer/tasks/dns_test.go
deleted file mode 100644
index 270a495..0000000
--- a/core/installer/tasks/dns_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package tasks
-
-import (
-	"context"
-	"net"
-	"testing"
-	"time"
-)
-
-func TestGoogle(t *testing.T) {
-	ctx, _ := context.WithTimeout(context.Background(), 30*time.Second)
-	d := NewDNSResolverTask(
-		"welcome.t5.lekva.me",
-		[]net.IP{
-			net.ParseIP("135.181.48.180"),
-			net.ParseIP("65.108.39.172"),
-		},
-		ctx,
-		t.Logf,
-	)
-	d.FinalizeSubtasks()
-	ch := make(chan struct{})
-	d.OnDone(func(err error) {
-		if err != nil {
-			t.Logf("%s\n", err.Error())
-		} else {
-			t.Logf("Dooone")
-		}
-		ch <- struct{}{}
-	})
-	d.Start()
-	<-ch
-}
diff --git a/core/installer/tasks/env.go b/core/installer/tasks/env.go
index 340648c..353eb4c 100644
--- a/core/installer/tasks/env.go
+++ b/core/installer/tasks/env.go
@@ -1,8 +1,6 @@
 package tasks
 
 import (
-	"context"
-	"fmt"
 	"net"
 
 	"github.com/charmbracelet/keygen"
@@ -20,13 +18,6 @@
 	keys         *keygen.KeyPair
 }
 
-type createEnvTask struct {
-	basicTask
-	env              Env
-	st               state
-	createConfigRepo Task
-}
-
 type Env struct {
 	PCloudEnvName  string
 	Name           string
@@ -41,62 +32,18 @@
 	nsCreator installer.NamespaceCreator,
 	repo installer.RepoIO,
 ) Task {
-	ctx := context.Background()
-	e := &createEnvTask{
-		basicTask: basicTask{
-			title: fmt.Sprintf("Create %s environment", env.Domain),
-		},
-		env: env,
-		st: state{
-			publicIPs: publicIPs,
-			nsCreator: nsCreator,
-			repo:      repo,
-		},
+	st := state{
+		publicIPs: publicIPs,
+		nsCreator: nsCreator,
+		repo:      repo,
 	}
-	e.createConfigRepo = NewCreateConfigRepoTask(env, &e.st)
-	e.AddSubtask(e.createConfigRepo)
-	initRepo := NewInitConfigRepoTask(env, &e.st)
-	e.AddSubtask(initRepo)
-	e.createConfigRepo.OnDone(func(err error) {
-		if err == nil {
-			initRepo.Start()
-		} else {
-			e.callDoneListeners(err)
-		}
-	})
-	activate := NewActivateEnvTask(env, &e.st)
-	e.AddSubtask(activate)
-	initRepo.OnDone(func(err error) {
-		if err == nil {
-			activate.Start()
-		} else {
-			e.callDoneListeners(err)
-		}
-	})
-	dns := NewDNSResolverTask(env.Domain, publicIPs, ctx, env, &e.st)
-	e.AddSubtask(dns)
-	activate.OnDone(func(err error) {
-		if err == nil {
-			dns.Start()
-		} else {
-			e.callDoneListeners(err)
-		}
-	})
-	setupInfra := NewSetupInfraAppsTask(env, &e.st)
-	e.AddSubtask(setupInfra)
-	dns.OnDone(func(err error) {
-		if err == nil {
-			setupInfra.Start()
-		} else {
-			e.callDoneListeners(err)
-		}
-	})
-	setupInfra.OnDone(func(err error) {
-		e.callDoneListeners(err)
-	})
-	return e
-}
-
-func (t *createEnvTask) Start() {
-	go t.createConfigRepo.Start()
+	t := newSequentialParentTask(
+		"Create env",
+		NewCreateConfigRepoTask(env, &st),
+		NewInitConfigRepoTask(env, &st),
+		NewActivateEnvTask(env, &st),
+		NewDNSResolverTask(env.Domain, publicIPs, env, &st),
+		NewSetupInfraAppsTask(env, &st),
+	)
+	return &t
 }
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index e57c789..06de0dc 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -16,29 +16,6 @@
 	st  *state
 }
 
-func NewSetupInfraAppsTask(env Env, st *state) Task {
-	return &setupInfraAppsTask{
-		basicTask: basicTask{
-			title: "Configure environment infrastructure",
-		},
-		env: env,
-		st:  st,
-	}
-}
-
-func (t *setupInfraAppsTask) Start() {
-	repo, err := t.st.ssClient.GetRepo("config")
-	if err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	if err := t.initNewEnv(t.st.ssClient, installer.NewRepoIO(repo, t.st.ssClient.Signer), t.st.nsCreator, t.env.PCloudEnvName, t.st.publicIPs[0].String()); err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	t.callDoneListeners(nil)
-}
-
 func (t *setupInfraAppsTask) initNewEnv(
 	ss *soft.Client,
 	r installer.RepoIO,
@@ -46,33 +23,43 @@
 	pcloudEnvName string,
 	pcloudPublicIP string,
 ) error {
-	appManager, err := installer.NewAppManager(r, nsCreator)
-	if err != nil {
-		return err
-	}
-	appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
-	// TODO(giolekva): private domain can be configurable as well
-	config := installer.Config{
-		Values: installer.Values{
-			PCloudEnvName:   pcloudEnvName,
-			Id:              t.env.Name,
-			ContactEmail:    t.env.ContactEmail,
-			Domain:          t.env.Domain,
-			PrivateDomain:   fmt.Sprintf("p.%s", t.env.Domain),
-			PublicIP:        pcloudPublicIP,
-			NamespacePrefix: fmt.Sprintf("%s-", t.env.Name),
-		},
-	}
-	if err := r.WriteYaml("config.yaml", config); err != nil {
-		return err
-	}
-	{
-		out, err := r.Writer("pcloud-charts.yaml")
+	return nil
+}
+
+func NewSetupInfraAppsTask(env Env, st *state) Task {
+	t := newLeafTask("Configure environment infrastructure", func() error {
+		repo, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
 		}
-		defer out.Close()
-		_, err = fmt.Fprintf(out, `
+		r := installer.NewRepoIO(repo, st.ssClient.Signer)
+		appManager, err := installer.NewAppManager(r, st.nsCreator)
+		if err != nil {
+			return err
+		}
+		appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+		// 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),
+			},
+		}
+		if err := r.WriteYaml("config.yaml", config); err != nil {
+			return err
+		}
+		{
+			out, err := r.Writer("pcloud-charts.yaml")
+			if err != nil {
+				return err
+			}
+			defer out.Close()
+			_, err = fmt.Fprintf(out, `
 apiVersion: source.toolkit.fluxcd.io/v1
 kind: GitRepository
 metadata:
@@ -83,167 +70,169 @@
   url: https://github.com/giolekva/pcloud
   ref:
     branch: main
-`, t.env.Name)
+`, env.Name)
+			if err != nil {
+				return err
+			}
+		}
+		rootKust, err := r.ReadKustomization("kustomization.yaml")
 		if err != nil {
 			return err
 		}
-	}
-	rootKust, err := r.ReadKustomization("kustomization.yaml")
-	if err != nil {
-		return err
-	}
-	rootKust.AddResources("pcloud-charts.yaml")
-	if err := r.WriteKustomization("kustomization.yaml", *rootKust); err != nil {
-		return err
-	}
-	r.CommitAndPush("configure charts repo")
-	nsGen := installer.NewPrefixGenerator(t.env.Name + "-")
-	emptySuffixGen := installer.NewEmptySuffixGenerator()
-	ingressPrivateIP, err := netip.ParseAddr("10.1.0.1")
-	if err != nil {
-		return err
-	}
-	{
-		headscaleIP := ingressPrivateIP.Next()
-		app, err := appsRepo.Find("metallb-ipaddresspool")
+		rootKust.AddResources("pcloud-charts.yaml")
+		if err := r.WriteKustomization("kustomization.yaml", *rootKust); err != nil {
+			return err
+		}
+		r.CommitAndPush("configure charts repo")
+		nsGen := installer.NewPrefixGenerator(env.Name + "-")
+		emptySuffixGen := installer.NewEmptySuffixGenerator()
+		ingressPrivateIP, err := netip.ParseAddr("10.1.0.1")
 		if err != nil {
 			return err
 		}
-		if err := appManager.Install(*app, nsGen, installer.NewSuffixGenerator("-ingress-private"), map[string]any{
-			"Name":       fmt.Sprintf("%s-ingress-private", t.env.Name),
-			"From":       ingressPrivateIP.String(),
-			"To":         ingressPrivateIP.String(),
-			"AutoAssign": false,
-			"Namespace":  "metallb-system",
-		}); err != nil {
-			return err
+		{
+			headscaleIP := ingressPrivateIP.Next()
+			app, err := appsRepo.Find("metallb-ipaddresspool")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, nsGen, installer.NewSuffixGenerator("-ingress-private"), map[string]any{
+				"Name":       fmt.Sprintf("%s-ingress-private", env.Name),
+				"From":       ingressPrivateIP.String(),
+				"To":         ingressPrivateIP.String(),
+				"AutoAssign": false,
+				"Namespace":  "metallb-system",
+			}); err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, nsGen, installer.NewSuffixGenerator("-headscale"), map[string]any{
+				"Name":       fmt.Sprintf("%s-headscale", env.Name),
+				"From":       headscaleIP.String(),
+				"To":         headscaleIP.String(),
+				"AutoAssign": false,
+				"Namespace":  "metallb-system",
+			}); err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
+				"Name":       env.Name,
+				"From":       "10.1.0.100", // TODO(gio): auto-generate
+				"To":         "10.1.0.254",
+				"AutoAssign": false,
+				"Namespace":  "metallb-system",
+			}); err != nil {
+				return err
+			}
 		}
-		if err := appManager.Install(*app, nsGen, installer.NewSuffixGenerator("-headscale"), map[string]any{
-			"Name":       fmt.Sprintf("%s-headscale", t.env.Name),
-			"From":       headscaleIP.String(),
-			"To":         headscaleIP.String(),
-			"AutoAssign": false,
-			"Namespace":  "metallb-system",
-		}); err != nil {
-			return err
+		{
+			app, err := appsRepo.Find("private-network")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
+				"PrivateNetwork": map[string]any{
+					"Hostname": "private-network-proxy",
+					"Username": "private-network-proxy",
+					"IPSubnet": "10.1.0.0/24",
+				},
+			}); err != nil {
+				return err
+			}
 		}
-		if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-			"Name":       t.env.Name,
-			"From":       "10.1.0.100", // TODO(gio): auto-generate
-			"To":         "10.1.0.254",
-			"AutoAssign": false,
-			"Namespace":  "metallb-system",
-		}); err != nil {
-			return err
+		{
+			app, err := appsRepo.Find("certificate-issuer-public")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{}); err != nil {
+				return err
+			}
 		}
-	}
-	{
-		app, err := appsRepo.Find("private-network")
-		if err != nil {
-			return err
+		{
+			app, err := appsRepo.Find("certificate-issuer-private")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, nsGen, emptySuffixGen, 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),
+				},
+			}); err != nil {
+				return err
+			}
 		}
-		if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-			"PrivateNetwork": map[string]any{
-				"Hostname": "private-network-proxy",
-				"Username": "private-network-proxy",
-				"IPSubnet": "10.1.0.0/24",
-			},
-		}); err != nil {
-			return err
+		{
+			app, err := appsRepo.Find("core-auth")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
+				"Subdomain": "test", // TODO(giolekva): make core-auth chart actually use this
+			}); err != nil {
+				return err
+			}
 		}
-	}
-	{
-		app, err := appsRepo.Find("certificate-issuer-public")
-		if err != nil {
-			return err
+		{
+			app, err := appsRepo.Find("headscale")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
+				"Subdomain": "headscale",
+			}); err != nil {
+				return err
+			}
 		}
-		if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{}); err != nil {
-			return err
+		{
+			keys, err := installer.NewSSHKeyPair("welcome")
+			if err != nil {
+				return err
+			}
+			user := fmt.Sprintf("%s-welcome", env.Name)
+			if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
+				return err
+			}
+			if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil {
+				return err
+			}
+			app, err := appsRepo.Find("welcome")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
+				"RepoAddr":      st.ssClient.GetRepoAddress("config"),
+				"SSHPrivateKey": string(keys.RawPrivateKey()),
+			}); err != nil {
+				return err
+			}
 		}
-	}
-	{
-		app, err := appsRepo.Find("certificate-issuer-private")
-		if err != nil {
-			return err
+		{
+			user := fmt.Sprintf("%s-appmanager", env.Name)
+			keys, err := installer.NewSSHKeyPair(user)
+			if err != nil {
+				return err
+			}
+			if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
+				return err
+			}
+			if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil {
+				return err
+			}
+			app, err := appsRepo.Find("app-manager") // TODO(giolekva): configure
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
+				"RepoAddr":      st.ssClient.GetRepoAddress("config"),
+				"SSHPrivateKey": string(keys.RawPrivateKey()),
+			}); err != nil {
+				return err
+			}
 		}
-		if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-			"APIConfigMap": map[string]any{
-				"Name":      "api-config", // TODO(gio): take from global pcloud config
-				"Namespace": fmt.Sprintf("%s-dns-zone-manager", pcloudEnvName),
-			},
-		}); err != nil {
-			return err
-		}
-	}
-	{
-		app, err := appsRepo.Find("core-auth")
-		if err != nil {
-			return err
-		}
-		if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-			"Subdomain": "test", // TODO(giolekva): make core-auth chart actually use this
-		}); err != nil {
-			return err
-		}
-	}
-	{
-		app, err := appsRepo.Find("headscale")
-		if err != nil {
-			return err
-		}
-		if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-			"Subdomain": "headscale",
-		}); err != nil {
-			return err
-		}
-	}
-	{
-		keys, err := installer.NewSSHKeyPair("welcome")
-		if err != nil {
-			return err
-		}
-		user := fmt.Sprintf("%s-welcome", t.env.Name)
-		if err := ss.AddUser(user, keys.AuthorizedKey()); err != nil {
-			return err
-		}
-		if err := ss.AddReadWriteCollaborator("config", user); err != nil {
-			return err
-		}
-		app, err := appsRepo.Find("welcome")
-		if err != nil {
-			return err
-		}
-		if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-			"RepoAddr":      ss.GetRepoAddress("config"),
-			"SSHPrivateKey": string(keys.RawPrivateKey()),
-		}); err != nil {
-			return err
-		}
-	}
-	{
-		user := fmt.Sprintf("%s-appmanager", t.env.Name)
-		keys, err := installer.NewSSHKeyPair(user)
-		if err != nil {
-			return err
-		}
-		if err := ss.AddUser(user, keys.AuthorizedKey()); err != nil {
-			return err
-		}
-		if err := ss.AddReadWriteCollaborator("config", user); err != nil {
-			return err
-		}
-		app, err := appsRepo.Find("app-manager") // TODO(giolekva): configure
-		if err != nil {
-			return err
-		}
-		if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-			"RepoAddr":      ss.GetRepoAddress("config"),
-			"SSHPrivateKey": string(keys.RawPrivateKey()),
-		}); err != nil {
-			return err
-		}
-	}
-	return nil
+		return nil
+	})
+	return &t
 }
 
 type DNSSecKey struct {
diff --git a/core/installer/tasks/init.go b/core/installer/tasks/init.go
index 1e4ada4..2457c4d 100644
--- a/core/installer/tasks/init.go
+++ b/core/installer/tasks/init.go
@@ -9,134 +9,95 @@
 	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
-type createConfigRepoTask struct {
-	basicTask
-	env Env
-	st  *state
-}
-
 func NewCreateConfigRepoTask(env Env, st *state) Task {
-	return &createConfigRepoTask{
-		basicTask: basicTask{
-			title: "Install Git server",
-		},
-		env: env,
-		st:  st,
-	}
-}
-
-func (t *createConfigRepoTask) Start() {
-	appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
-	ssApp, err := appsRepo.Find("soft-serve")
-	if err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	ssAdminKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-admin-keys", t.env.Name))
-	if err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	ssKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-keys", t.env.Name))
-	if err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	ssValues := map[string]any{
-		"ChartRepositoryNamespace": t.env.PCloudEnvName,
-		"ServiceType":              "ClusterIP",
-		"PrivateKey":               string(ssKeys.RawPrivateKey()),
-		"PublicKey":                string(ssKeys.RawAuthorizedKey()),
-		"AdminKey":                 string(ssAdminKeys.RawAuthorizedKey()),
-		"Ingress": map[string]any{
-			"Enabled": false,
-		},
-	}
-	derived := installer.Derived{
-		Global: installer.Values{
-			Id:            t.env.Name,
-			PCloudEnvName: t.env.PCloudEnvName,
-		},
-		Release: installer.Release{
-			Namespace: t.env.Name,
-		},
-		Values: ssValues,
-	}
-	if err := t.st.nsCreator.Create(t.env.Name); err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	if err := t.st.repo.InstallApp(*ssApp, filepath.Join("/environments", t.env.Name, "config-repo"), ssValues, derived); err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	ssClient, err := soft.WaitForClient(
-		fmt.Sprintf("soft-serve.%s.svc.cluster.local:%d", t.env.Name, 22),
-		ssAdminKeys.RawPrivateKey(),
-		log.Default())
-	if err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	if err := ssClient.AddPublicKey("admin", t.env.AdminPublicKey); err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	// // TODO(gio): defer?
-	// // TODO(gio): remove at the end of final task cleanup
-	// if err := ssClient.RemovePublicKey("admin", string(ssAdminKeys.RawAuthorizedKey())); err != nil {
-	// 	t.callDoneListeners(err)
-	// 	return
-	// }
-	t.st.ssClient = ssClient
-	t.callDoneListeners(nil)
-}
-
-type initConfigRepoTask struct {
-	basicTask
-	env Env
-	st  *state
+	t := newLeafTask("Install Git server", func() error {
+		appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+		ssApp, err := appsRepo.Find("soft-serve")
+		if err != nil {
+			return err
+		}
+		ssAdminKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-admin-keys", env.Name))
+		if err != nil {
+			return err
+		}
+		ssKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-keys", env.Name))
+		if err != nil {
+			return err
+		}
+		ssValues := map[string]any{
+			"ChartRepositoryNamespace": env.PCloudEnvName,
+			"ServiceType":              "ClusterIP",
+			"PrivateKey":               string(ssKeys.RawPrivateKey()),
+			"PublicKey":                string(ssKeys.RawAuthorizedKey()),
+			"AdminKey":                 string(ssAdminKeys.RawAuthorizedKey()),
+			"Ingress": map[string]any{
+				"Enabled": false,
+			},
+		}
+		derived := installer.Derived{
+			Global: installer.Values{
+				Id:            env.Name,
+				PCloudEnvName: env.PCloudEnvName,
+			},
+			Release: installer.Release{
+				Namespace: env.Name,
+			},
+			Values: ssValues,
+		}
+		if err := st.nsCreator.Create(env.Name); err != nil {
+			return err
+		}
+		if err := st.repo.InstallApp(*ssApp, filepath.Join("/environments", env.Name, "config-repo"), ssValues, derived); err != nil {
+			return err
+		}
+		ssClient, err := soft.WaitForClient(
+			fmt.Sprintf("soft-serve.%s.svc.cluster.local:%d", env.Name, 22),
+			ssAdminKeys.RawPrivateKey(),
+			log.Default())
+		if err != nil {
+			return err
+		}
+		if err := ssClient.AddPublicKey("admin", env.AdminPublicKey); err != nil {
+			return err
+		}
+		// // TODO(gio): defer?
+		// // TODO(gio): remove at the end of final task cleanup
+		// if err := ssClient.RemovePublicKey("admin", string(ssAdminKeys.RawAuthorizedKey())); err != nil {
+		// 	t.callDoneListeners(err)
+		// 	return
+		// }
+		st.ssClient = ssClient
+		return nil
+	})
+	return &t
 }
 
 func NewInitConfigRepoTask(env Env, st *state) Task {
-	return &initConfigRepoTask{
-		basicTask: basicTask{
-			title: "Create Git repository for environment configuration",
-		},
-		env: env,
-		st:  st,
-	}
-}
-
-func (t *initConfigRepoTask) Start() {
-	t.st.fluxUserName = fmt.Sprintf("flux-%s", t.env.Name)
-	keys, err := installer.NewSSHKeyPair(t.st.fluxUserName)
-	if err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	t.st.keys = keys
-	if err := t.st.ssClient.AddRepository("config"); err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	repo, err := t.st.ssClient.GetRepo("config")
-	if err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	repoIO := installer.NewRepoIO(repo, t.st.ssClient.Signer)
-	if err := repoIO.WriteCommitAndPush("README.md", fmt.Sprintf("# %s PCloud environment", t.env.Name), "readme"); err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	if err := t.st.ssClient.AddUser(t.st.fluxUserName, keys.AuthorizedKey()); err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	if err := t.st.ssClient.AddReadOnlyCollaborator("config", t.st.fluxUserName); err != nil {
-		t.callDoneListeners(err)
-		return
-	}
-	t.callDoneListeners(nil)
+	t := newLeafTask("Create Git repository for environment configuration", func() error {
+		st.fluxUserName = fmt.Sprintf("flux-%s", env.Name)
+		keys, err := installer.NewSSHKeyPair(st.fluxUserName)
+		if err != nil {
+			return err
+		}
+		st.keys = keys
+		if err := st.ssClient.AddRepository("config"); err != nil {
+			return err
+		}
+		repo, err := st.ssClient.GetRepo("config")
+		if err != nil {
+			return err
+		}
+		repoIO := installer.NewRepoIO(repo, st.ssClient.Signer)
+		if err := repoIO.WriteCommitAndPush("README.md", fmt.Sprintf("# %s PCloud environment", env.Name), "readme"); err != nil {
+			return err
+		}
+		if err := st.ssClient.AddUser(st.fluxUserName, keys.AuthorizedKey()); err != nil {
+			return err
+		}
+		if err := st.ssClient.AddReadOnlyCollaborator("config", st.fluxUserName); err != nil {
+			return err
+		}
+		return nil
+	})
+	return &t
 }
diff --git a/core/installer/tasks/tasks.go b/core/installer/tasks/tasks.go
index 4eb834d..9b0d533 100644
--- a/core/installer/tasks/tasks.go
+++ b/core/installer/tasks/tasks.go
@@ -1,9 +1,5 @@
 package tasks
 
-import (
-	"fmt"
-)
-
 type Status int
 
 const (
@@ -21,8 +17,6 @@
 	Status() Status
 	Err() error
 	Subtasks() []Task
-	AddSubtask(t Task) error
-	FinalizeSubtasks()
 	OnDone(l TaskDoneListener)
 }
 
@@ -30,12 +24,18 @@
 	title     string
 	status    Status
 	err       error
-	subtasks  []Task
-	done      []bool
-	finalized bool
 	listeners []TaskDoneListener
 }
 
+func newBasicTask(title string) basicTask {
+	return basicTask{
+		title:     title,
+		status:    StatusPending,
+		err:       nil,
+		listeners: make([]TaskDoneListener, 0),
+	}
+}
+
 func (b *basicTask) Title() string {
 	return b.title
 }
@@ -48,47 +48,6 @@
 	return b.err
 }
 
-func (b *basicTask) Subtasks() []Task {
-	return b.subtasks
-}
-
-func (b *basicTask) AddSubtask(t Task) error {
-	if b.finalized {
-		return fmt.Errorf("already finalized")
-	}
-	i := len(b.subtasks)
-	b.subtasks = append(b.subtasks, t)
-	b.done = append(b.done, false)
-	t.OnDone(func(err error) {
-		if b.done[i] {
-			panic(fmt.Sprintf("already done: %s", b.subtasks[i].Title()))
-		}
-		b.done[i] = true
-		if err != nil {
-			b.callDoneListeners(err)
-		}
-		if !b.finalized {
-			return
-		}
-		done := 0
-		for _, d := range b.done {
-			if d {
-				done++
-			} else {
-				break
-			}
-		}
-		if done == len(b.subtasks) {
-			b.callDoneListeners(nil)
-		}
-	})
-	return nil
-}
-
-func (b *basicTask) FinalizeSubtasks() {
-	b.finalized = true
-}
-
 func (b *basicTask) OnDone(l TaskDoneListener) {
 	b.listeners = append(b.listeners, l)
 }
@@ -104,3 +63,68 @@
 		b.err = err
 	}
 }
+
+type leafTask struct {
+	basicTask
+	start func() error
+}
+
+func newLeafTask(title string, start func() error) leafTask {
+	return leafTask{
+		basicTask: newBasicTask(title),
+		start:     start,
+	}
+}
+
+func (b *leafTask) Subtasks() []Task {
+	return make([]Task, 0)
+}
+
+func (b *leafTask) Start() {
+	b.callDoneListeners(b.start())
+}
+
+type parentTask struct {
+	leafTask
+	subtasks []Task
+}
+
+func newParentTask(title string, start func() error, subtasks ...Task) parentTask {
+	return parentTask{
+		leafTask: newLeafTask(title, start),
+		subtasks: subtasks,
+	}
+}
+
+func (t *parentTask) Subtasks() []Task {
+	return t.subtasks
+}
+
+type sequentialParentTask struct {
+	parentTask
+}
+
+func newSequentialParentTask(title string, subtasks ...Task) sequentialParentTask {
+	start := func() error {
+		errCh := make(chan error)
+		for i := range subtasks[:len(subtasks)-1] {
+			next := i + 1
+			subtasks[i].OnDone(func(err error) {
+				if err == nil {
+					go subtasks[next].Start()
+				} else {
+					errCh <- err
+				}
+			})
+		}
+		subtasks[len(subtasks)-1].OnDone(func(err error) {
+			errCh <- err
+		})
+		go subtasks[0].Start()
+		return <-errCh
+	}
+	t := sequentialParentTask{
+		parentTask: newParentTask(title, start, subtasks...),
+	}
+	return t
+}
diff --git a/core/installer/tasks/tasks_test.go b/core/installer/tasks/tasks_test.go
new file mode 100644
index 0000000..7aa78f3
--- /dev/null
+++ b/core/installer/tasks/tasks_test.go
@@ -0,0 +1,80 @@
+package tasks
+
+import (
+	"fmt"
+	"testing"
+)
+
+func TestLeaf(t *testing.T) {
+	l := newLeafTask("leaf", func() error {
+		return nil
+	})
+	done := make(chan error)
+	l.OnDone(func(err error) {
+		done <- err
+	})
+	go l.Start()
+	err := <-done
+	if err != nil {
+		t.Fatalf("Expected nil, got %s", err.Error())
+	}
+}
+
+func TestSequentialSuccess(t *testing.T) {
+	one := newLeafTask("one", func() error {
+		return nil
+	})
+	two := newLeafTask("two", func() error {
+		return nil
+	})
+	l := newSequentialParentTask("parent", &one, &two)
+	done := make(chan error)
+	l.OnDone(func(err error) {
+		done <- err
+	})
+	go l.Start()
+	err := <-done
+	if err != nil {
+		t.Fatalf("Expected nil, got %s", err.Error())
+	}
+}
+
+func TestSequentialFailsFirst(t *testing.T) {
+	one := newLeafTask("one", func() error {
+		return fmt.Errorf("one")
+	})
+	two := newLeafTask("two", func() error {
+		return nil
+	})
+	l := newSequentialParentTask("parent", &one, &two)
+	done := make(chan error)
+	l.OnDone(func(err error) {
+		done <- err
+	})
+	go l.Start()
+	err := <-done
+	if err == nil || err.Error() != "one" {
+		t.Fatalf("Expected one, got %s", err)
+	}
+}
+
+func TestSequentialFailsSecond(t *testing.T) {
+	one := newLeafTask("one", func() error {
+		fmt.Println("one")
+		return nil
+	})
+	two := newLeafTask("two", func() error {
+		fmt.Println("two")
+		return fmt.Errorf("two")
+	})
+	l := newSequentialParentTask("parent", &one, &two)
+	done := make(chan error)
+	l.OnDone(func(err error) {
+		done <- err
+	})
+	go l.Start()
+	err := <-done
+	if err == nil || err.Error() != "two" {
+		t.Fatalf("Expected two, got %s", err)
+	}
+}
diff --git a/core/installer/welcome/env-created.html b/core/installer/welcome/env-created.html
index 47eab2b..26f551a 100644
--- a/core/installer/welcome/env-created.html
+++ b/core/installer/welcome/env-created.html
@@ -20,7 +20,7 @@
 		<meta charset="utf-8" />
 		<meta name="viewport" content="width=device-width, initial-scale=1" />
 		{{ if not (or (eq .Root.Status 2) (eq .Root.Status 3))}}
-		<meta http-equiv="refresh" content="1000">
+		<meta http-equiv="refresh" content="2">
 		{{ end }}
 	</head>
 	<body>
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index bb8b6ff..9dd2abc 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -227,10 +227,10 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	if err := s.acceptInvitation(req.SecretToken); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
+	// if err := s.acceptInvitation(req.SecretToken); err != nil {
+	// 	http.Error(w, err.Error(), http.StatusInternalServerError)
+	// 	return
+	// }
 	if name, err := s.nameGenerator.Generate(); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -253,6 +253,6 @@
 		s.repo,
 	)
 	s.tasks["foo"] = t
-	t.Start()
+	go t.Start()
 	http.Redirect(w, r, "/env/foo", http.StatusSeeOther)
 }