diff --git a/README.md b/README.md
index 4b1317f06d677de4da99e0e42f35c8788aecb0a1..34f5615eaff536c2c03505da2d32ab50fe32656a 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,11 @@ any hardware supporting Java (e.g. unix server, raspberry pi) could be added.
 
 [briarproject.org](https://briarproject.org/)
 
+## Server CLI version
+
+A fat JAR for running on a GNU/Linux server can be compiled with
+
+    ./gradlew x86LinuxJar
 
 ## Donate 
 [![Donate using Liberapay](https://briarproject.org/img/liberapay.svg)](https://liberapay.com/Briar/donate) [![Flattr this](https://briarproject.org/img/flattr-badge-large.png "Flattr this")](https://flattr.com/t/592836/)   
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt
index fce42c9512b07a41dac70fab0c703f8c3a69999e..918541561ffd28db6cb217b45024757e2e3dbd19 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt
@@ -45,20 +45,22 @@ import javax.inject.Singleton
 internal class AppModule {
     @Singleton
     @Provides
-    fun provideDatabaseConfig(app: Application) = object : DatabaseConfig {
+    fun provideDatabaseConfig(fileProvider: FileProvider) = object : DatabaseConfig {
         override fun getDatabaseDirectory(): File {
-            return app.applicationContext.getDir("db", MODE_PRIVATE)
+            // The database itself does mkdirs() and we use the existence to see if DB exists
+            return File(fileProvider.root, "db")
         }
     }
 
     @Singleton
     @Provides
     fun provideFileProvider(app: Application) = object : FileProvider {
-        private val tempFilesDir = File(app.applicationContext.cacheDir, "tmp").also { it.mkdirs() }
+        override val root: File get() = app.applicationContext.filesDir
         override val folderRoot = app.applicationContext.getDir("folders", MODE_PRIVATE)
+        private val tempFilesDir = File(app.applicationContext.cacheDir, "tmp").apply { mkdirs() }
 
         override fun getTemporaryFile(fileId: String) = File(tempFilesDir, fileId)
-        override fun getFolder(folderId: String) = File(folderRoot, folderId).also { it.mkdirs() }
+        override fun getFolder(folderId: String) = File(folderRoot, folderId).apply { mkdirs() }
         override fun getFile(folderId: String, fileId: String) = File(getFolder(folderId), fileId)
     }
 
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/StartReceiver.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/StartReceiver.kt
index 9865d44008e9c131cd57ec6d083f8440b87ffabb..0719c1be073aea16f69d4a0766e2fe4992c68153 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/StartReceiver.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/StartReceiver.kt
@@ -27,6 +27,7 @@ import android.content.Intent.ACTION_MY_PACKAGE_REPLACED
 import dagger.hilt.android.AndroidEntryPoint
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState.NOT_STARTED
+import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.util.LogUtils.debug
 import org.slf4j.LoggerFactory.getLogger
 import javax.inject.Inject
@@ -39,10 +40,16 @@ class StartReceiver : BroadcastReceiver() {
     @Inject
     internal lateinit var lifecycleManager: LifecycleManager
 
+    @Inject
+    internal lateinit var setupManager: SetupManager
+
     override fun onReceive(context: Context, intent: Intent) {
         val action = intent.action
         if (action != ACTION_BOOT_COMPLETED && action != ACTION_MY_PACKAGE_REPLACED) return
 
+        // don't start, if we don't even have a database
+        if (!setupManager.hasDb) return
+
         val lifecycleState = lifecycleManager.lifecycleStateFlow.value
         LOG.debug { "Received $action in state ${lifecycleState.name}" }
         if (lifecycleState == NOT_STARTED) {
diff --git a/mailbox-cli/build.gradle b/mailbox-cli/build.gradle
index 51a8dfb078c35023282fc4bbf4fb6a3c87cc794e..b64d6ce7600a85c76f9a30425b6733806932189a 100644
--- a/mailbox-cli/build.gradle
+++ b/mailbox-cli/build.gradle
@@ -1,3 +1,9 @@
+import java.util.jar.JarEntry
+import java.util.jar.JarFile
+import java.util.jar.JarOutputStream
+
+import static java.util.Collections.list
+
 plugins {
     id 'application'
     id 'idea'
@@ -83,6 +89,70 @@ processResources {
     dependsOn unpackTorBinaries
 }
 
+void jarFactory(Jar jarTask, jarArchitecture) {
+    jarTask.doFirst {
+        println 'Building ' + jarArchitecture + ' version has started'
+    }
+    jarTask.manifest {
+        attributes(
+                'Main-Class': application.getMainClass()
+        )
+    }
+    jarTask.setArchiveClassifier(jarArchitecture)
+    jarTask.from {
+        configurations.runtimeClasspath.collect { file ->
+            file.isDirectory() ? file : zipTree(file)
+        }
+    } { copySpec ->
+        copySpec.duplicatesStrategy(DuplicatesStrategy.EXCLUDE)
+        String[] architectures = ["linux-aarch64", "linux-armhf", "linux-x86_64"]
+        for (String arch : architectures) {
+            if (arch != jarArchitecture) {
+                exclude "obfs4proxy_" + arch + ".zip"
+                exclude "tor_" + arch + ".zip"
+            }
+        }
+        exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA'
+    }
+    jarTask.with jar
+    jarTask.doLast {
+        // Rename the original jar
+        File jar = getArchiveFile().get().getAsFile()
+        String srcPath = jar.toString().replaceFirst('\\.jar$', '.unsorted.jar')
+        File srcFile = new File(srcPath)
+        jar.renameTo(srcFile)
+        JarFile srcJarFile = new JarFile(srcFile)
+        OutputStream destStream = new JarOutputStream(new FileOutputStream(jar))
+        // Read and sort the entries
+        Map<String, JarEntry> entries = new TreeMap<>()
+        for (JarEntry e : list(srcJarFile.entries())) entries.put(e.getName(), e)
+        // Write the sorted entries
+        for (JarEntry srcEntry : entries.values()) {
+            JarEntry destEntry = new JarEntry(srcEntry.getName())
+            destEntry.setTime(0)
+            destStream.putNextEntry(destEntry)
+            InputStream srcStream = srcJarFile.getInputStream(srcEntry)
+            int read
+            byte[] buf = new byte[4096]
+            while ((read = srcStream.read(buf, 0, buf.length)) != -1) {
+                destStream.write(buf, 0, read)
+            }
+            destStream.closeEntry()
+            srcStream.close()
+        }
+        destStream.close()
+        srcJarFile.close()
+        println 'Building ' + jarArchitecture + ' version has finished'
+        println 'JAR: mailbox-cli/build/libs/mailbox-cli-linux-x86_64.jar'
+    }
+}
+
+task x86LinuxJar(type: Jar) {
+    group = "Build"
+    description = "Assembles a runnable fat jar for x86-64 Linux"
+    jarFactory(it, 'linux-x86_64')
+}
+
 tasks.withType(Test) {
     systemProperty 'java.library.path', 'libs'
 }
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt
index 58774946a9b32cbb472412908c022c1fc6a0d32b..1e90dcadd2f9929bea369ae47a054aeb263a2685 100644
--- a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt
@@ -61,6 +61,11 @@ internal class JavaCliModule {
         private const val DATAHOME_SUBDIR = "briar-mailbox"
     }
 
+    /**
+     * Returns the [File] for the data directory of the mailbox.
+     * If XDG_DATA_HOME is defined, it returns "$XDG_DATA_HOME/briar-mailbox"
+     * and otherwise it returns "~/.local/share/briar-mailbox".
+     */
     private val dataDir: File by lazy {
         val dataHome = when (val custom = System.getenv("XDG_DATA_HOME").orEmpty()) {
             "" -> File(DEFAULT_DATAHOME)
@@ -89,26 +94,22 @@ internal class JavaCliModule {
 
     @Singleton
     @Provides
-    fun provideDatabaseConfig() = object : DatabaseConfig {
+    fun provideDatabaseConfig(fileProvider: FileProvider) = object : DatabaseConfig {
         override fun getDatabaseDirectory(): File {
-            val dbDir = File(dataDir, "db")
-            if (!dbDir.exists() && !dbDir.mkdirs()) {
-                throw IOException("dbDir could not be created: ${dbDir.absolutePath}")
-            } else if (!dbDir.isDirectory) {
-                throw IOException("dbDir is not a directory: ${dbDir.absolutePath}")
-            }
-            return dbDir
+            // The database itself does mkdirs() and we use the existence to see if DB exists
+            return File(fileProvider.root, "db")
         }
     }
 
     @Singleton
     @Provides
     fun provideFileProvider() = object : FileProvider {
-        private val tempFilesDir = File(dataDir, "tmp").also { it.mkdirs() }
-        override val folderRoot = File(dataDir, "folders").also { it.mkdirs() }
+        override val root: File get() = dataDir
+        private val tempFilesDir = File(dataDir, "tmp").apply { mkdirs() }
+        override val folderRoot = File(dataDir, "folders").apply { mkdirs() }
 
         override fun getTemporaryFile(fileId: String) = File(tempFilesDir, fileId)
-        override fun getFolder(folderId: String) = File(folderRoot, folderId).also { it.mkdirs() }
+        override fun getFolder(folderId: String) = File(folderRoot, folderId).apply { mkdirs() }
         override fun getFile(folderId: String, fileId: String) = File(getFolder(folderId), fileId)
     }
 
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt
index 44cf1c5ed05f073620b16a7a257481b6c2820198..1c0c0514fdc0510a6a566e491bbd8543e69f47e2 100644
--- a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt
@@ -25,13 +25,17 @@ import com.github.ajalt.clikt.core.CliktCommand
 import com.github.ajalt.clikt.parameters.options.counted
 import com.github.ajalt.clikt.parameters.options.flag
 import com.github.ajalt.clikt.parameters.options.option
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.runBlocking
 import org.briarproject.mailbox.core.CoreEagerSingletons
 import org.briarproject.mailbox.core.JavaCliEagerSingletons
 import org.briarproject.mailbox.core.db.TransactionManager
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager
 import org.briarproject.mailbox.core.setup.QrCodeEncoder
 import org.briarproject.mailbox.core.setup.SetupManager
+import org.briarproject.mailbox.core.setup.WipeManager
 import org.briarproject.mailbox.core.system.InvalidIdException
+import org.briarproject.mailbox.core.tor.TorPlugin
 import org.slf4j.LoggerFactory.getLogger
 import java.util.logging.Level.ALL
 import java.util.logging.Level.INFO
@@ -44,6 +48,10 @@ class Main : CliktCommand(
     name = "briar-mailbox",
     help = "Command line interface for the Briar Mailbox"
 ) {
+    private val wipe by option(
+        "--wipe",
+        help = "Deletes entire mailbox, will require new setup",
+    ).flag(default = false)
     private val debug by option("--debug", "-d", help = "Enable printing of debug messages").flag(
         default = false
     )
@@ -69,6 +77,12 @@ class Main : CliktCommand(
     @Inject
     internal lateinit var setupManager: SetupManager
 
+    @Inject
+    internal lateinit var wipeManager: WipeManager
+
+    @Inject
+    internal lateinit var torPlugin: TorPlugin
+
     @Inject
     internal lateinit var qrCodeEncoder: QrCodeEncoder
 
@@ -93,6 +107,15 @@ class Main : CliktCommand(
         val javaCliComponent = DaggerJavaCliComponent.builder().build()
         javaCliComponent.inject(this)
 
+        if (wipe) {
+            wipeManager.wipeFilesOnly()
+            println("Mailbox wiped successfully \\o/")
+            exitProcess(0)
+        }
+        startLifecycle()
+    }
+
+    private fun startLifecycle() {
         Runtime.getRuntime().addShutdownHook(
             Thread {
                 lifecycleManager.stopServices()
@@ -103,23 +126,36 @@ class Main : CliktCommand(
         lifecycleManager.startServices()
         lifecycleManager.waitForStartup()
 
-        if (setupToken != null) try {
-            setupManager.setToken(setupToken, null)
-        } catch (e: InvalidIdException) {
-            System.err.println("Invalid setup token")
-            exitProcess(1)
+        if (setupToken != null) {
+            try {
+                setupManager.setToken(setupToken, null)
+            } catch (e: InvalidIdException) {
+                System.err.println("Invalid setup token")
+                exitProcess(1)
+            }
         }
 
-        // TODO this is obviously not the final code, just a stub to get us started
-        val setupTokenExists = db.read { txn ->
-            setupManager.getSetupToken(txn) != null
-        }
         val ownerTokenExists = db.read { txn ->
             setupManager.getOwnerToken(txn) != null
         }
-        if (!setupTokenExists && !ownerTokenExists) setupManager.restartSetup()
-        qrCodeEncoder.getQrCodeBitMatrix()?.let {
-            println(QrCodeRenderer.getQrString(it))
+        if (!ownerTokenExists) {
+            if (debug) {
+                val token = setupToken ?: db.read { setupManager.getSetupToken(it) }
+                println(
+                    "curl -v -H \"Authorization: Bearer $token\" -X PUT " +
+                        "http://localhost:8000/setup"
+                )
+            }
+            // If not set up, show QR code for manual setup
+            runBlocking {
+                // wait until Tor becomes active and published the onion service
+                torPlugin.state.takeWhile { state ->
+                    state != TorPlugin.State.ACTIVE
+                }
+            }
+            qrCodeEncoder.getQrCodeBitMatrix()?.let {
+                println(QrCodeRenderer.getQrString(it))
+            }
         }
     }
 
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt
index 31dce9ca6947a43adfccaa3d6a91619ba62da8af..631bfebf1a2b6559c73761f963e50b26c10099e5 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt
@@ -28,6 +28,7 @@ import org.briarproject.mailbox.core.event.EventModule
 import org.briarproject.mailbox.core.lifecycle.LifecycleModule
 import org.briarproject.mailbox.core.server.WebServerModule
 import org.briarproject.mailbox.core.settings.SettingsModule
+import org.briarproject.mailbox.core.setup.SetupModule
 import org.briarproject.mailbox.core.system.Clock
 import org.briarproject.mailbox.core.tor.TorModule
 import javax.inject.Singleton
@@ -37,6 +38,7 @@ import javax.inject.Singleton
         EventModule::class,
         LifecycleModule::class,
         DatabaseModule::class,
+        SetupModule::class,
         WebServerModule::class,
         SettingsModule::class,
         TorModule::class,
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/files/FileManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/files/FileManager.kt
index dc113ea23a3d4c46047d6147f2b2bc426b377e5e..8cc0869936a2c50288b8afb435c344174a1c151d 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/files/FileManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/files/FileManager.kt
@@ -28,9 +28,12 @@ import io.ktor.response.respondFile
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import org.briarproject.mailbox.core.db.Database
+import org.briarproject.mailbox.core.db.DatabaseConfig
 import org.briarproject.mailbox.core.server.AuthException
 import org.briarproject.mailbox.core.server.AuthManager
 import org.briarproject.mailbox.core.server.MailboxPrincipal
+import org.briarproject.mailbox.core.setup.SetupManager
+import org.briarproject.mailbox.core.setup.WipeManager
 import org.briarproject.mailbox.core.system.InvalidIdException
 import org.briarproject.mailbox.core.system.RandomIdManager
 import org.slf4j.LoggerFactory.getLogger
@@ -40,10 +43,23 @@ private val LOG = getLogger(FileManager::class.java)
 
 class FileManager @Inject constructor(
     private val fileProvider: FileProvider,
+    private val dbConfig: DatabaseConfig,
 ) {
+
+    /**
+     * Used by [SetupManager] to test for the existence of the database.
+     */
+    fun hasDbFile(): Boolean {
+        val dbDir = dbConfig.getDatabaseDirectory()
+        return dbDir.isDirectory
+    }
+
+    /**
+     * Used by [WipeManager] to wipe all files.
+     */
     fun deleteAllFiles(): Boolean {
         var allDeleted = true
-        fileProvider.folderRoot.listFiles()?.forEach { folder ->
+        fileProvider.root.listFiles()?.forEach { folder ->
             if (!folder.deleteRecursively()) {
                 allDeleted = false
                 LOG.warn("Not everything in $folder could get deleted.")
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/files/FileProvider.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/files/FileProvider.kt
index b8a10f5ca19268d10b931580cd80f9d12ca3ac32..660a790b93060a8c4d546731b19636b78b879f2c 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/files/FileProvider.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/files/FileProvider.kt
@@ -22,6 +22,14 @@ package org.briarproject.mailbox.core.files
 import java.io.File
 
 interface FileProvider {
+    /**
+     * The root files directory.
+     * Attention: This is not guaranteed to be the parent of other files on all platforms.
+     *            Also this directory and all of its content are deleted during wipe,
+     *            so make sure this is a directory where this doesn't do any harm,
+     *            i.e. a directory used for the mailbox only.
+     */
+    val root: File
     val folderRoot: File
     fun getTemporaryFile(fileId: String): File
     fun getFolder(folderId: String): File
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManager.kt
index d7ab1ea2c916015a349fbe2f4759e825d9794d11..2b631bfb16cec4ac91567e81501a574a8c69de0b 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManager.kt
@@ -41,4 +41,11 @@ interface SettingsManager {
      */
     @Throws(DbException::class)
     fun mergeSettings(s: Settings, namespace: String)
+
+    /**
+     * Merges the given settings with any existing settings in the given
+     * namespace.
+     */
+    @Throws(DbException::class)
+    fun mergeSettings(txn: Transaction, s: Settings, namespace: String)
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt
index 49930785522aae6c61dbfc03482a462b0221bdea..a8e404ebeca97c86917ae59c77e59f26a6a1e598 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt
@@ -43,4 +43,8 @@ internal class SettingsManagerImpl(private val db: Database) : SettingsManager {
     override fun mergeSettings(s: Settings, namespace: String) {
         db.write { txn -> db.mergeSettings(txn, s, namespace) }
     }
+
+    override fun mergeSettings(txn: Transaction, s: Settings, namespace: String) {
+        db.mergeSettings(txn, s, namespace)
+    }
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
index c74d1fac3fba7e77b253d48935d122a1a58b4216..faccb6fdde94d89dff9cfccee8c0e0901dc98006 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
@@ -25,6 +25,8 @@ import io.ktor.http.HttpStatusCode
 import io.ktor.response.respond
 import org.briarproject.mailbox.core.db.DbException
 import org.briarproject.mailbox.core.db.Transaction
+import org.briarproject.mailbox.core.files.FileManager
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager.OpenDatabaseHook
 import org.briarproject.mailbox.core.server.AuthException
 import org.briarproject.mailbox.core.server.AuthManager
 import org.briarproject.mailbox.core.settings.Settings
@@ -36,23 +38,52 @@ private const val SETTINGS_NAMESPACE_OWNER = "owner"
 private const val SETTINGS_SETUP_TOKEN = "setupToken"
 private const val SETTINGS_OWNER_TOKEN = "ownerToken"
 
-class SetupManager @Inject constructor(
+interface SetupManager : OpenDatabaseHook {
+    /**
+     * True if a database has been setup.
+     * This is usually the case, if the lifecycle has been started once.
+     * The Mailbox might still need pairing/linking.
+     * This is false after wiping.
+     */
+    val hasDb: Boolean
+
+    @Throws(DbException::class)
+    fun setToken(setupToken: String?, ownerToken: String?)
+
+    @Throws(DbException::class)
+    fun getSetupToken(txn: Transaction): String?
+
+    @Throws(DbException::class)
+    fun getOwnerToken(txn: Transaction): String?
+}
+
+class SetupManagerImpl @Inject constructor(
     private val randomIdManager: RandomIdManager,
     private val settingsManager: SettingsManager,
-) {
+    private val fileManager: FileManager,
+) : SetupManager {
 
-    /**
-     * Stores a new single-use setup token and wipes the owner auth token, if one existed.
-     */
-    fun restartSetup() {
-        val settings = Settings()
-        settings[SETTINGS_SETUP_TOKEN] = randomIdManager.getNewRandomId()
-        settings[SETTINGS_OWNER_TOKEN] = null
-        settingsManager.mergeSettings(settings, SETTINGS_NAMESPACE_OWNER)
+    override val hasDb: Boolean get() = fileManager.hasDbFile()
+
+    @Throws(DbException::class)
+    override fun onDatabaseOpened(txn: Transaction) {
+        val settings = settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER)
+        val setupToken = settings[SETTINGS_SETUP_TOKEN]
+        val ownerToken = settings[SETTINGS_OWNER_TOKEN]
+        // ensure that setup token is initialized if both tokens are empty
+        if (setupToken == null && ownerToken == null) {
+            settings[SETTINGS_SETUP_TOKEN] = randomIdManager.getNewRandomId()
+            settingsManager.mergeSettings(txn, settings, SETTINGS_NAMESPACE_OWNER)
+        }
     }
 
+    /**
+     * Sets either the [setupToken] or the [ownerToken].
+     * Can not set both at once.
+     */
     @Throws(DbException::class)
-    fun setToken(setupToken: String?, ownerToken: String?) {
+    override fun setToken(setupToken: String?, ownerToken: String?) {
+        require(setupToken == null || ownerToken == null) { "Can not set both tokens" }
         val settings = Settings()
         if (setupToken != null) randomIdManager.assertIsRandomId(setupToken)
         settings[SETTINGS_SETUP_TOKEN] = setupToken
@@ -61,13 +92,14 @@ class SetupManager @Inject constructor(
         settingsManager.mergeSettings(settings, SETTINGS_NAMESPACE_OWNER)
     }
 
-    fun getSetupToken(txn: Transaction): String? {
+    @Throws(DbException::class)
+    override fun getSetupToken(txn: Transaction): String? {
         val settings = settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER)
         return settings[SETTINGS_SETUP_TOKEN]
     }
 
     @Throws(DbException::class)
-    fun getOwnerToken(txn: Transaction): String? {
+    override fun getOwnerToken(txn: Transaction): String? {
         val settings = settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER)
         return settings[SETTINGS_OWNER_TOKEN]
     }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..55b9fe2fdedc497ed9e3530d2f3953ae369c7715
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupModule.kt
@@ -0,0 +1,39 @@
+/*
+ *     Briar Mailbox
+ *     Copyright (C) 2021-2022  The Briar Project
+ *
+ *     This program is free software: you can redistribute it and/or modify
+ *     it under the terms of the GNU Affero General Public License as
+ *     published by the Free Software Foundation, either version 3 of the
+ *     License, or (at your option) any later version.
+ *
+ *     This program is distributed in the hope that it will be useful,
+ *     but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *     GNU Affero General Public License for more details.
+ *
+ *     You should have received a copy of the GNU Affero General Public License
+ *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+package org.briarproject.mailbox.core.setup
+
+import dagger.Module
+import dagger.Provides
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import javax.inject.Singleton
+
+@Module
+class SetupModule {
+    @Provides
+    @Singleton
+    fun provideSetupManager(
+        lifecycleManager: LifecycleManager,
+        setupManagerImpl: SetupManagerImpl,
+    ): SetupManager {
+        return setupManagerImpl.also {
+            lifecycleManager.registerOpenDatabaseHook(it)
+        }
+    }
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/WipeManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/WipeManager.kt
index 59629b3cd8459a2a03b562559ce2f84f13dba434..7cefaaebb256539775eea64cf40606656dcf1b72 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/WipeManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/WipeManager.kt
@@ -24,33 +24,33 @@ import io.ktor.auth.principal
 import io.ktor.http.HttpStatusCode
 import io.ktor.response.respond
 import org.briarproject.mailbox.core.db.Database
-import org.briarproject.mailbox.core.db.DatabaseConfig
 import org.briarproject.mailbox.core.files.FileManager
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager
 import org.briarproject.mailbox.core.server.AuthException
 import org.briarproject.mailbox.core.server.MailboxPrincipal
 import org.briarproject.mailbox.core.server.MailboxPrincipal.OwnerPrincipal
-import org.briarproject.mailbox.core.util.IoUtils
 import javax.inject.Inject
 
 class WipeManager @Inject constructor(
     private val db: Database,
-    private val databaseConfig: DatabaseConfig,
     private val fileManager: FileManager,
 ) {
 
-    /*
-     * This must only be called by the LifecycleManager
+    /**
+     * Drops database tables and then deletes all files, includes the database files.
+     *
+     * This must only be called by the [LifecycleManager].
      */
     fun wipeDatabaseAndFiles() {
         db.dropAllTablesAndClose()
-        val dir = databaseConfig.getDatabaseDirectory()
-        IoUtils.deleteFileOrDir(dir)
         fileManager.deleteAllFiles()
     }
 
-    /*
-     * This must only be called by the LifecycleManager
+    /**
+     * Deletes all files, includes the database files.
+     *
+     * This must only be called by the [LifecycleManager]
+     * or by the CLI when no lifecycle was started.
      */
     fun wipeFilesOnly() {
         fileManager.deleteAllFiles()
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
index ab2bbd79673fdc25445e7dbae238bd415e5d5426..73f881b47dfd9e931d5fc0b0db9809e0c6d0a59e 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
@@ -59,10 +59,14 @@ import javax.annotation.Nullable;
 import javax.annotation.concurrent.GuardedBy;
 import javax.annotation.concurrent.ThreadSafe;
 
+import kotlinx.coroutines.flow.MutableStateFlow;
+import kotlinx.coroutines.flow.StateFlow;
+
 import static java.util.Arrays.asList;
 import static java.util.Collections.singletonList;
 import static java.util.Collections.singletonMap;
 import static java.util.Objects.requireNonNull;
+import static kotlinx.coroutines.flow.StateFlowKt.MutableStateFlow;
 import static net.freehaven.tor.control.TorControlCommands.HS_ADDRESS;
 import static net.freehaven.tor.control.TorControlCommands.HS_PRIVKEY;
 import static org.briarproject.mailbox.core.tor.TorConstants.CONTROL_PORT;
@@ -70,7 +74,6 @@ import static org.briarproject.mailbox.core.tor.TorConstants.HS_ADDRESS_V3;
 import static org.briarproject.mailbox.core.tor.TorConstants.HS_PRIVATE_KEY_V3;
 import static org.briarproject.mailbox.core.tor.TorConstants.SETTINGS_NAMESPACE;
 import static org.briarproject.mailbox.core.tor.TorPlugin.State.ACTIVE;
-import static org.briarproject.mailbox.core.tor.TorPlugin.State.DISABLED;
 import static org.briarproject.mailbox.core.tor.TorPlugin.State.ENABLING;
 import static org.briarproject.mailbox.core.tor.TorPlugin.State.INACTIVE;
 import static org.briarproject.mailbox.core.tor.TorPlugin.State.STARTING_STOPPING;
@@ -155,6 +158,10 @@ public abstract class TorPlugin
 		return new File(torDirectory, "obfs4proxy");
 	}
 
+	public StateFlow<State> getState() {
+		return state.state;
+	}
+
 	@Override
 	public void startService() throws ServiceException {
 		if (used.getAndSet(true)) throw new IllegalStateException();
@@ -404,6 +411,7 @@ public abstract class TorPlugin
 				logException(LOG, e);
 			}
 		}
+		state.setServicePublished();
 	}
 
 	@Nullable
@@ -534,7 +542,6 @@ public abstract class TorPlugin
 				else LOG.info("Country code: " + country);
 			}
 
-			int reasonsDisabled = 0;
 			boolean enableNetwork = false;
 			boolean enableBridges = false;
 			boolean useMeek = false;
@@ -557,7 +564,6 @@ public abstract class TorPlugin
 					LOG.info("Not using bridges");
 				}
 			}
-			state.setReasonsDisabled(reasonsDisabled);
 			try {
 				if (enableNetwork) {
 					enableBridges(enableBridges, useMeek);
@@ -581,7 +587,10 @@ public abstract class TorPlugin
 	}
 
 	@ThreadSafe
-	protected class PluginState {
+	protected static class PluginState {
+
+		private final MutableStateFlow<State> state =
+				MutableStateFlow(STARTING_STOPPING);
 
 		@GuardedBy("this")
 		private boolean started = false,
@@ -590,10 +599,7 @@ public abstract class TorPlugin
 				networkEnabled = false,
 				bootstrapped = false,
 				circuitBuilt = false,
-				settingsChecked = false;
-
-		@GuardedBy("this")
-		private int reasonsDisabled = 0;
+				servicePublished = false;
 
 		@GuardedBy("this")
 		@Nullable
@@ -601,7 +607,7 @@ public abstract class TorPlugin
 
 		synchronized void setStarted() {
 			started = true;
-//            callback.pluginStateChanged(getState());
+			state.setValue(getCurrentState());
 		}
 
 		synchronized boolean isTorRunning() {
@@ -613,63 +619,52 @@ public abstract class TorPlugin
 			stopped = true;
 			ServerSocket ss = serverSocket;
 			serverSocket = null;
-//            callback.pluginStateChanged(getState());
+			state.setValue(getCurrentState());
 			return ss;
 		}
 
 		synchronized void setBootstrapped() {
 			bootstrapped = true;
-//            callback.pluginStateChanged(getState());
+			state.setValue(getCurrentState());
 		}
 
 		synchronized boolean getAndSetCircuitBuilt() {
 			boolean firstCircuit = !circuitBuilt;
 			circuitBuilt = true;
-//            callback.pluginStateChanged(getState());
+			state.setValue(getCurrentState());
 			return firstCircuit;
 		}
 
+		synchronized void setServicePublished() {
+			servicePublished = true;
+			state.setValue(getCurrentState());
+		}
+
 		synchronized void enableNetwork(boolean enable) {
 			networkInitialised = true;
 			networkEnabled = enable;
 			if (!enable) circuitBuilt = false;
-//            callback.pluginStateChanged(getState());
+			state.setValue(getCurrentState());
 		}
 
-		synchronized void setReasonsDisabled(int reasonsDisabled) {
-			settingsChecked = true;
-			this.reasonsDisabled = reasonsDisabled;
-//            callback.pluginStateChanged(getState());
-		}
-
-		synchronized State getState() {
-			if (!started || stopped || !settingsChecked) {
+		private synchronized State getCurrentState() {
+			if (!started || stopped) {
 				return STARTING_STOPPING;
 			}
-			if (reasonsDisabled != 0) return DISABLED;
 			if (!networkInitialised) return ENABLING;
 			if (!networkEnabled) return INACTIVE;
-			return bootstrapped && circuitBuilt ? ACTIVE : ENABLING;
-		}
-
-		synchronized int getReasonsDisabled() {
-			return getState() == DISABLED ? reasonsDisabled : 0;
+			return bootstrapped && circuitBuilt && servicePublished ?
+					ACTIVE : ENABLING;
 		}
 
 	}
 
-	enum State {
-
+	public enum State {
 		/**
 		 * The plugin has not finished starting or has been stopped.
 		 */
 		STARTING_STOPPING,
 
-		/**
-		 * The plugin is disabled by settings.
-		 */
-		DISABLED,
-
 		/**
 		 * The plugin is being enabled and can't yet make or receive
 		 * connections.
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
index e0ad498eb2764b4fd53066fb45a2cbf2f7feda97..960874cd8156d7c84da414d9c281237a3510881a 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
@@ -9,7 +9,7 @@ import org.briarproject.mailbox.core.lifecycle.LifecycleManager
 import org.briarproject.mailbox.core.settings.MetadataManager
 import org.briarproject.mailbox.core.settings.SettingsManager
 import org.briarproject.mailbox.core.setup.SetupManager
-import org.briarproject.mailbox.core.system.RandomIdManager
+import org.briarproject.mailbox.core.setup.WipeManager
 import javax.inject.Singleton
 
 @Singleton
@@ -26,7 +26,7 @@ interface TestComponent {
     fun getFileManager(): FileManager
     fun getDatabaseConfig(): DatabaseConfig
     fun getDatabase(): Database
-    fun getRandomIdManager(): RandomIdManager
     fun getFileProvider(): FileProvider
     fun getMetadataManager(): MetadataManager
+    fun getWipeManager(): WipeManager
 }
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestModule.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestModule.kt
index 2a1adc06bdf8bc09bb97f6dd91fae266dd1cdf42..5ac0c7dfe6ef609fee33651b68b7d308457b7257 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestModule.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestModule.kt
@@ -10,6 +10,7 @@ import org.briarproject.mailbox.core.files.FileProvider
 import org.briarproject.mailbox.core.lifecycle.LifecycleModule
 import org.briarproject.mailbox.core.server.WebServerModule
 import org.briarproject.mailbox.core.settings.SettingsModule
+import org.briarproject.mailbox.core.setup.SetupModule
 import org.briarproject.mailbox.core.system.Clock
 import java.io.File
 import javax.inject.Singleton
@@ -18,6 +19,7 @@ import javax.inject.Singleton
     includes = [
         LifecycleModule::class,
         TestDatabaseModule::class,
+        SetupModule::class,
         WebServerModule::class,
         SettingsModule::class,
         // no Tor module
@@ -40,11 +42,16 @@ internal class TestModule(private val tempDir: File) {
     @Singleton
     @Provides
     fun provideFileProvider() = object : FileProvider {
-        private val tempFilesDir = File(tempDir, "tmp").also { it.mkdirs() }
+        override val root: File get() = tempDir
         override val folderRoot = File(tempDir, "folders")
+        private val tempFilesDir = File(tempDir, "tmp").apply { mkdirs() }
 
-        override fun getTemporaryFile(fileId: String) = File(tempFilesDir, fileId)
-        override fun getFolder(folderId: String) = File(folderRoot, folderId).also { it.mkdirs() }
+        override fun getTemporaryFile(fileId: String) = File(tempFilesDir, fileId).apply {
+            // we delete root at the end of each test, so tempFilesDir gets deleted as well
+            parentFile.mkdirs()
+        }
+
+        override fun getFolder(folderId: String) = File(folderRoot, folderId).apply { mkdirs() }
         override fun getFile(folderId: String, fileId: String) = File(getFolder(folderId), fileId)
     }
 }
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/files/FileManagerIntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/files/FileManagerIntegrationTest.kt
index de2f1d0461bd0ded29e70e91c0a353160794caec..fd0d67b97374bf1a1ce6a4865ea2cbe3fa801430 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/files/FileManagerIntegrationTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/files/FileManagerIntegrationTest.kt
@@ -12,7 +12,6 @@ import kotlinx.coroutines.runBlocking
 import org.briarproject.mailbox.core.TestUtils.assertTimestampRecent
 import org.briarproject.mailbox.core.TestUtils.getNewRandomId
 import org.briarproject.mailbox.core.server.IntegrationTest
-import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.Assertions.assertArrayEquals
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
@@ -31,12 +30,6 @@ class FileManagerIntegrationTest : IntegrationTest() {
         addContact(contact2)
     }
 
-    @AfterEach
-    override fun clearDb() {
-        super.clearDb()
-        testComponent.getFileManager().deleteAllFiles()
-    }
-
     @Test
     fun `post new file rejects wrong token`(): Unit = runBlocking {
         val response: HttpResponse = httpClient.post("$baseUrl/files/${getNewRandomId()}") {
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt
index 10afd983ebd1403e75c3a426035ff35d0a80efdb..bbb9f151b9da66d78110d5b3687f2be17be3807c 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt
@@ -24,6 +24,8 @@ import org.junit.jupiter.api.TestInstance
 import org.junit.jupiter.api.TestInstance.Lifecycle
 import org.junit.jupiter.api.io.TempDir
 import java.io.File
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
 
 @TestInstance(Lifecycle.PER_CLASS)
 abstract class IntegrationTest(private val installJsonFeature: Boolean = true) {
@@ -31,7 +33,9 @@ abstract class IntegrationTest(private val installJsonFeature: Boolean = true) {
     protected lateinit var testComponent: TestComponent
     protected val db by lazy { testComponent.getDatabase() }
     private val lifecycleManager by lazy { testComponent.getLifecycleManager() }
+    protected val setupManager by lazy { testComponent.getSetupManager() }
     protected val metadataManager by lazy { testComponent.getMetadataManager() }
+    private val wipeManager by lazy { testComponent.getWipeManager() }
     protected val httpClient = HttpClient(CIO) {
         expectSuccess = false // prevents exceptions on non-success responses
         if (installJsonFeature) {
@@ -55,6 +59,7 @@ abstract class IntegrationTest(private val installJsonFeature: Boolean = true) {
     fun setUp(@TempDir tempDir: File) {
         testComponent = DaggerTestComponent.builder().testModule(TestModule(tempDir)).build()
         testComponent.injectCoreEagerSingletons()
+        assertFalse(setupManager.hasDb)
         lifecycleManager.startServices()
         lifecycleManager.waitForStartup()
     }
@@ -73,11 +78,13 @@ abstract class IntegrationTest(private val installJsonFeature: Boolean = true) {
             // clears [metadataManager.ownerConnectionTime]
             metadataManager.onDatabaseOpened(txn)
         }
+        assertTrue(setupManager.hasDb)
     }
 
     @AfterEach
-    open fun clearDb() {
-        db.dropAllTablesAndClose()
+    open fun wipe() {
+        wipeManager.wipeDatabaseAndFiles()
+        assertFalse(setupManager.hasDb)
     }
 
     protected fun addOwnerToken() {
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt
index 83e40b887e92326ca36b829f853641d6726cbe9a..86f28ca64de352f0761dcffd1f02c41a56a0c85f 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt
@@ -7,38 +7,10 @@ import kotlinx.coroutines.runBlocking
 import org.briarproject.mailbox.core.server.IntegrationTest
 import org.junit.jupiter.api.Test
 import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 
 class SetupManagerTest : IntegrationTest() {
 
-    private val setupManager by lazy { testComponent.getSetupManager() }
-
-    @Test
-    fun `restarting setup wipes owner token and creates setup token`() {
-        // initially, there's no setup and no owner token
-        db.read { txn ->
-            assertNull(setupManager.getSetupToken(txn))
-            assertNull(setupManager.getOwnerToken(txn))
-        }
-
-        // setting an owner token stores it in DB
-        setupManager.setToken(null, ownerToken)
-        db.read { txn ->
-            assertNull(setupManager.getSetupToken(txn))
-            assertEquals(ownerToken, setupManager.getOwnerToken(txn))
-        }
-
-        // restarting setup wipes owner token, creates setup token
-        setupManager.restartSetup()
-        db.read { txn ->
-            val setupToken = setupManager.getSetupToken(txn)
-            assertNotNull(setupToken)
-            testComponent.getRandomIdManager().assertIsRandomId(setupToken)
-            assertNull(setupManager.getOwnerToken(txn))
-        }
-    }
-
     @Test
     fun `setup request gets rejected when using non-setup token`() = runBlocking {
         // initially, there's no setup and no owner token
@@ -78,6 +50,9 @@ class SetupManagerTest : IntegrationTest() {
         // set a setup-token
         setupManager.setToken(token, null)
 
+        // we are not yet set up
+        assertNull(db.read { txn -> setupManager.getOwnerToken(txn) })
+
         // use it for setup PUT request
         val response: SetupResponse = httpClient.put("$baseUrl/setup") {
             authenticateWithToken(token)
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipingWipeRouteManagerTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipingWipeRouteManagerTest.kt
index 984af156d0059376039d3ce0b0d83397210c3857..d29fd75bae37f94104c8088ee9ec1d754a872c9a 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipingWipeRouteManagerTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipingWipeRouteManagerTest.kt
@@ -41,7 +41,11 @@ class WipingWipeRouteManagerTest : IntegrationTest() {
 
         // no more files are stored
         val folderRoot = testComponent.getFileProvider().folderRoot
-        assertTrue(folderRoot.listFiles()?.isEmpty() ?: false)
+        assertFalse(folderRoot.exists())
+
+        // file root has been cleared
+        val root = testComponent.getFileProvider().root
+        assertTrue(root.listFiles()?.isEmpty() ?: false)
 
         // no more contacts in DB - contacts table is gone
         // it actually fails because db is closed though