diff --git a/ask/ask.go b/ask/ask.go
index 1626a1ce62bc4ca6efef5e9a61ebc1522d45412d..e8d6a9cdea473f36ff30647f64632eefdfc4f3bc 100644
--- a/ask/ask.go
+++ b/ask/ask.go
@@ -4,7 +4,20 @@ getting a line of text, getting a choice from a liat and other things.
 */
 package ask
 
-import "github.com/chzyer/readline"
+import (
+	"fmt"
+	"os"
+
+	"github.com/chzyer/readline"
+)
+
+// CheckErr prints an error message and exits.
+func CheckErr(err error) {
+	if err != nil {
+		fmt.Printf("ERROR: %s\n", err)
+		os.Exit(1)
+	}
+}
 
 // GetLine gets a line.
 func GetLine(prompt string) (string, error) {
diff --git a/batch/batch.go b/batch/batch.go
index 1763f04332f0f200c59a31127521b6e01f1cef0f..3bba087fdaca6fa146d0a8984e1f71d21ad3d0d2 100644
--- a/batch/batch.go
+++ b/batch/batch.go
@@ -2,11 +2,19 @@
 package batch
 
 import (
+	"context"
 	_ "embed"
+	"errors"
 	"fmt"
 	"os"
+	"path"
 	"strings"
 	"text/template"
+
+	"git.lyda.ie/kevin/bulletin/ask"
+	"git.lyda.ie/kevin/bulletin/key"
+	"git.lyda.ie/kevin/bulletin/storage"
+	"github.com/adrg/xdg"
 )
 
 //go:embed crontab
@@ -26,6 +34,44 @@ func Expire() int {
 
 // Install is an interactive command used to install the crontab.
 func Install() int {
+	// Check if install has run before.
+	touchfile := path.Join(xdg.Home, ".bulletin-installed")
+	if _, err := os.Stat(touchfile); err == nil {
+		ask.CheckErr(errors.New("~/.bulletin-installed exists; BULLETIN install has run"))
+	} else {
+		if !errors.Is(err, os.ErrNotExist) {
+			fmt.Println("ERROR: Unknown error checking in ~/.bulletin-installed exists.")
+			fmt.Println("ERROR: Can't see if BULLETIN has been installed.")
+			ask.CheckErr(err)
+		}
+	}
+
+	// Connect to the database.
+	store, err := storage.Open()
+	ask.CheckErr(err)
+	q := storage.New(store.DB)
+
+	// Create the initial users.
+	login, err := ask.GetLine("Enter login of initial user: ")
+	ask.CheckErr(err)
+	name, err := ask.GetLine("Enter name of initial user: ")
+	ask.CheckErr(err)
+	sshkey, err := ask.GetLine("Enter ssh public key of initial user: ")
+	ask.CheckErr(err)
+
+	// Seed data.
+	ctx := context.TODO()
+	ask.CheckErr(q.SeedUserSystem(ctx))
+	ask.CheckErr(q.SeedFolderGeneral(ctx))
+	ask.CheckErr(q.SeedGeneralOwner(ctx))
+	_, err = q.AddUser(ctx, storage.AddUserParams{
+		Login: login,
+		Name:  name,
+	})
+	ask.CheckErr(q.SeedGeneralOwner(ctx))
+	key.Add(login, sshkey)
+
+	// Install crontab.
 	bulletin, err := os.Executable()
 	if err != nil {
 		panic(err) // TODO: cleanup error handling.
@@ -34,9 +80,16 @@ func Install() int {
 	template.Must(template.New("crontab").Parse(crontabTemplate)).
 		Execute(crontab, map[string]string{"Bulletin": bulletin})
 	fmt.Printf("Add this to crontab:\n\n%s\n", crontab.String())
+	err = installCrontab(crontab.String())
+	if err != nil {
+		panic(err) // TODO: cleanup error handling.
+	}
+
+	// Mark that install has happened.
+	err = touch(touchfile)
+	if err != nil {
+		panic(err) // TODO: cleanup error handling.
+	}
 
-	fmt.Println("TODO: Finish installing bulletin.")
-	// TODO: Offer to add ssh key for system user.
-	// TODO: Offer to add ssh key for other user(s).
 	return 0
 }
diff --git a/batch/file.go b/batch/file.go
new file mode 100644
index 0000000000000000000000000000000000000000..bdec4659819b95c0cb6c890372f696c10b394bf5
--- /dev/null
+++ b/batch/file.go
@@ -0,0 +1,55 @@
+package batch
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+)
+
+func touch(name string) error {
+	f, err := os.OpenFile(name, os.O_RDONLY|os.O_CREATE, 0666)
+	if err != nil {
+		return err
+	}
+	f.Close()
+	return nil
+}
+
+func installCrontab(crontab string) error {
+	// Make a temp file.
+	f, err := os.CreateTemp("", "crontab")
+	if err != nil {
+		return err
+	}
+	defer func() {
+		f.Close()
+		os.Remove(f.Name())
+	}()
+
+	// Put the crontab in the temp file.
+	n, err := f.WriteString(crontab)
+	if err != nil {
+		return err
+	}
+	if n != len(crontab) {
+		return fmt.Errorf("Failed to write crontab fully %d of %d chars written",
+			n, len(crontab))
+	}
+
+	// Have the crontab command read in the file.
+	cmd := exec.Command("crontab", f.Name())
+	cmd.Stdin = strings.NewReader("")
+	var stdout strings.Builder
+	cmd.Stdout = &stdout
+	var stderr strings.Builder
+	cmd.Stderr = &stdout
+	err = cmd.Run()
+	if err != nil {
+		fmt.Printf("ERROR: stdout: [%s]\n", stdout.String())
+		fmt.Printf("ERROR: stderr: [%s]\n", stderr.String())
+		return err
+	}
+
+	return nil
+}
diff --git a/key/key.go b/key/key.go
new file mode 100644
index 0000000000000000000000000000000000000000..cb4ade461f27fb0faa1d1159c60166a427d1461a
--- /dev/null
+++ b/key/key.go
@@ -0,0 +1,40 @@
+// Package key manages the authorized keys file.
+package key
+
+import (
+	"fmt"
+	"os"
+	"path"
+
+	"github.com/adrg/xdg"
+)
+
+var keytemplate = `command="%s -u %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s\n`
+
+// Add adds an ssh key to the `authorized_keys` file.
+func Add(login, public string) error {
+	bulletin, err := os.Executable()
+	if err != nil {
+		return err
+	}
+
+	keyline := fmt.Sprintf(keytemplate, bulletin, login, public)
+
+	keyfile := path.Join(xdg.Home, ".ssh", "authorized_keys")
+
+	f, err := os.OpenFile(keyfile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	n, err := f.WriteString(keyline)
+	if err != nil {
+		return err
+	}
+	if n != len(keyline) {
+		return fmt.Errorf("Failed to write authorized_key fully %d of %d chars written",
+			n, len(keyline))
+	}
+
+	return nil
+}
diff --git a/storage/connection.go b/storage/connection.go
index fcf7c97d9683dab16b58b603a8c7c922e5fa611b..ad10c3fccafb0ff54f209e3f1f75d5debdbde84b 100644
--- a/storage/connection.go
+++ b/storage/connection.go
@@ -1,17 +1,51 @@
 package storage
 
 import (
+	"embed"
 	"errors"
-
-	"github.com/jmoiron/sqlx"
+	"os"
+	"path"
 
 	// Included to connect to sqlite.
 	_ "github.com/golang-migrate/migrate/v4/database/sqlite"
 	_ "modernc.org/sqlite"
+
+	"github.com/adrg/xdg"
+	"github.com/golang-migrate/migrate/v4"
+	"github.com/golang-migrate/migrate/v4/source/iofs"
+	"github.com/jmoiron/sqlx"
 )
 
+//go:embed migrations/*.sql
+var migrationsFS embed.FS
+
 // Open opens the bulletin database.
-func Open(dbfile string) (*sqlx.DB, error) {
+func Open() (*sqlx.DB, error) {
+	// Determine path names and create components.
+	bulldir := path.Join(xdg.DataHome, "BULLETIN")
+	err := os.MkdirAll(bulldir, 0700)
+	if err != nil {
+		return nil, errors.New("bulletin directory problem")
+	}
+	dbfile := path.Join(bulldir, "bulletin.db")
+
+	// Run db migrations if needed.
+	migrations, err := iofs.New(migrationsFS, "migrations")
+	if err != nil {
+		return nil, err
+	}
+	m, err := migrate.NewWithSourceInstance("iofs", migrations,
+		"sqlite://"+dbfile+"?_pragma=foreign_keys(1)")
+	if err != nil {
+		return nil, err
+	}
+	defer m.Close()
+	err = m.Up()
+	if err != nil && err != migrate.ErrNoChange {
+		return nil, err
+	}
+
+	// Connect to the db.
 	db, err := sqlx.Connect("sqlite", "file://"+dbfile+"?_pragma=foreign_keys(1)")
 	if err != nil {
 		return nil, errors.New("bulletin database problem")
diff --git a/storage/migrate.go b/storage/migrate.go
deleted file mode 100644
index ab8f218ba5dd0b774610829d50eb55a5faa7d8dd..0000000000000000000000000000000000000000
--- a/storage/migrate.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package storage
-
-import (
-	"embed"
-
-	"github.com/golang-migrate/migrate/v4"
-	"github.com/golang-migrate/migrate/v4/source/iofs"
-
-	// Included to connect to sqlite.
-	_ "github.com/golang-migrate/migrate/v4/database/sqlite"
-	_ "modernc.org/sqlite"
-)
-
-//go:embed migrations/*.sql
-var migrationsFS embed.FS
-
-// Migrate creates and updates the database.
-func Migrate(dbfile string) error {
-	// Run db migrations if needed.
-	migrations, err := iofs.New(migrationsFS, "migrations")
-	if err != nil {
-		return err
-	}
-	m, err := migrate.NewWithSourceInstance("iofs", migrations,
-		"sqlite://"+dbfile+"?_pragma=foreign_keys(1)")
-	if err != nil {
-		return err
-	}
-	defer m.Close()
-	err = m.Up()
-	if err != nil && err != migrate.ErrNoChange {
-		return err
-	}
-	return nil
-}
diff --git a/this/this.go b/this/this.go
index 9154f1593a4d66fab99e30407eaab8e2adec8efc..55ce0aabff924ade98b3f2d93670d3091bf56bdb 100644
--- a/this/this.go
+++ b/this/this.go
@@ -13,22 +13,16 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"os"
-	"path"
 
 	"git.lyda.ie/kevin/bulletin/ask"
 	"git.lyda.ie/kevin/bulletin/storage"
 	"git.lyda.ie/kevin/bulletin/users"
-	"github.com/adrg/xdg"
 	"github.com/jmoiron/sqlx"
 )
 
 // User is the user for this session.
 var User storage.User
 
-// DBFile is the path for the storage.
-var DBFile string
-
 // Store is the store for this session.
 var Store *sqlx.DB
 
@@ -49,16 +43,8 @@ func StartThis(login, name string) error {
 		return err
 	}
 
-	// Run migrations.
-	bulldir := path.Join(xdg.DataHome, "BULLETIN")
-	err = os.MkdirAll(bulldir, 0700)
-	if err != nil {
-		return errors.New("bulletin directory problem")
-	}
-	DBFile := path.Join(bulldir, "bulletin.db")
-	storage.Migrate(DBFile)
-
-	Store, err = storage.Open(DBFile)
+	// Connect to the DB.
+	Store, err = storage.Open()
 	if err != nil {
 		return err
 	}