diff --git a/mailbox-core/build.gradle b/mailbox-core/build.gradle
index 303a1eddbf1bb02f4de758db24b8a9acc22c02b8..fc34695fd31e37055694ca56685ac3531d10e0f6 100644
--- a/mailbox-core/build.gradle
+++ b/mailbox-core/build.gradle
@@ -24,10 +24,15 @@ dependencies {
     implementation "io.ktor:ktor-server-netty:$ktorVersion"
     api "org.slf4j:slf4j-api:1.7.32"
 
+    testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
     testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
     testImplementation "org.junit.jupiter:junit-jupiter-params:$junit_version"
     testImplementation "org.junit.jupiter:junit-jupiter-engine:$junit_version"
     testImplementation "io.mockk:mockk:$mockk_version"
+    testImplementation "ch.qos.logback:logback-classic:1.2.5"
+    testImplementation "io.ktor:ktor-client-cio:$ktorVersion"
+    testImplementation "com.google.dagger:hilt-core:$hilt_version"
+    kaptTest "com.google.dagger:dagger-compiler:$hilt_version"
 }
 
 test {
@@ -37,4 +42,14 @@ test {
     }
 }
 
+// At the moment for non-Android projects we need to explicitly mark the code generated by kapt
+// as 'generated source code' for correct highlighting and resolve in IDE.
+idea {
+    module {
+        sourceDirs += file('build/generated/source/kapt/main')
+        testSourceDirs += file('build/generated/source/kapt/test')
+        generatedSourceDirs += file('build/generated/source/kapt/main')
+    }
+}
+
 apply from: "${rootProject.rootDir}/gradle/ktlint.gradle"
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
index 335367f3a458d93bb0aec78acd685c1db9adb53b..158843242ca549aacf661383ccd146a5ceede6f7 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
@@ -4,9 +4,6 @@ import io.ktor.application.install
 import io.ktor.features.CallLogging
 import io.ktor.server.engine.embeddedServer
 import io.ktor.server.netty.Netty
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
 import org.briarproject.mailbox.core.lifecycle.Service
 import org.slf4j.LoggerFactory.getLogger
 import javax.inject.Inject
@@ -16,7 +13,7 @@ import javax.inject.Singleton
 class WebServerManager @Inject constructor() : Service {
 
     internal companion object {
-        private const val PORT = 8888
+        internal const val PORT = 8000
         private val LOG = getLogger(WebServerManager::class.java)
     }
 
@@ -28,12 +25,9 @@ class WebServerManager @Inject constructor() : Service {
     }
 
     override fun startService() {
-        // hangs if not starting inside a coroutine
-        GlobalScope.launch(Dispatchers.IO) {
-            LOG.info("starting")
-            server.start(wait = true)
-            LOG.info("started")
-        }
+        LOG.info("starting")
+        server.start()
+        LOG.info("started")
     }
 
     override fun stopService() {
diff --git a/mailbox-core/src/main/resources/logback.xml b/mailbox-core/src/main/resources/logback.xml
new file mode 100644
index 0000000000000000000000000000000000000000..945c218bb1ab715b006497a8900d0954aeb3a5a4
--- /dev/null
+++ b/mailbox-core/src/main/resources/logback.xml
@@ -0,0 +1,12 @@
+<configuration>
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+        </encoder>
+    </appender>
+    <root level="trace">
+        <appender-ref ref="STDOUT"/>
+    </root>
+    <logger name="org.eclipse.jetty" level="INFO"/>
+    <logger name="io.netty" level="INFO"/>
+</configuration>
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
new file mode 100644
index 0000000000000000000000000000000000000000..b41ac5c317e00b69e523b35112fb64ca5ee01b89
--- /dev/null
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
@@ -0,0 +1,16 @@
+package org.briarproject.mailbox.core
+
+import dagger.Component
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import javax.inject.Singleton
+
+@Singleton
+@Component(
+    modules = [
+        TestModule::class,
+    ]
+)
+interface TestComponent {
+    fun injectCoreEagerSingletons(): CoreEagerSingletons
+    fun getLifecycleManager(): LifecycleManager
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..739283752583549163e7f64425f993acf3faa181
--- /dev/null
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestModule.kt
@@ -0,0 +1,26 @@
+package org.briarproject.mailbox.core
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.core.db.DatabaseModule
+import org.briarproject.mailbox.core.lifecycle.LifecycleModule
+import org.briarproject.mailbox.core.server.WebServerModule
+import org.briarproject.mailbox.core.system.Clock
+import javax.inject.Singleton
+
+@Module(
+    includes = [
+        LifecycleModule::class,
+        DatabaseModule::class,
+        WebServerModule::class,
+        // no Tor module
+    ]
+)
+@InstallIn(SingletonComponent::class)
+internal class TestModule {
+    @Singleton
+    @Provides
+    fun provideClock() = Clock { System.currentTimeMillis() }
+}
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/WebServerIntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/WebServerIntegrationTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f6d922246405b4bb3f4daa4d4e73c4eb235746c3
--- /dev/null
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/WebServerIntegrationTest.kt
@@ -0,0 +1,54 @@
+package org.briarproject.mailbox.core.server
+
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.request.get
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.readText
+import kotlinx.coroutines.runBlocking
+import org.briarproject.mailbox.core.DaggerTestComponent
+import org.briarproject.mailbox.core.server.WebServerManager.Companion.PORT
+import org.junit.jupiter.api.AfterAll
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.TestInstance.Lifecycle
+import kotlin.test.assertEquals
+
+@TestInstance(Lifecycle.PER_CLASS)
+class WebServerIntegrationTest {
+
+    private val testComponent = DaggerTestComponent.builder().build()
+    private val lifecycleManager = testComponent.getLifecycleManager()
+    private val httpClient = HttpClient(CIO) {
+        expectSuccess = false // prevents exceptions on non-success responses
+    }
+    private val baseUrl = "http://127.0.0.1:$PORT"
+
+    @BeforeAll
+    fun setUp() {
+        testComponent.injectCoreEagerSingletons()
+        lifecycleManager.startServices()
+        lifecycleManager.waitForStartup()
+    }
+
+    @AfterAll
+    fun tearDown() {
+        lifecycleManager.stopServices()
+        lifecycleManager.waitForShutdown()
+    }
+
+    @Test
+    fun routeRespondsWithHelloWorldString(): Unit = runBlocking {
+        val response: HttpResponse = httpClient.get("$baseUrl/")
+        assertEquals(200, response.status.value)
+        assertEquals("Hello world!", response.readText())
+    }
+
+    @Test
+    fun routeNotFound(): Unit = runBlocking {
+        val response: HttpResponse = httpClient.get("$baseUrl/404")
+        assertEquals(404, response.status.value)
+    }
+
+}