diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 45c626658dcd12246c12f7070fdbdc577c8af37a..c218933a45ee37e99d3b9d197da3b0662d8e2c1f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,7 +31,8 @@
       - run: cargo --version
       - run: cargo test --locked --all-targets --all-features
       - run: cargo test --locked --release --test auth --test ssh
-      - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh
+      - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes --test serve
+      - run: cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
       - run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture
       - run: cargo build --locked --release
       - run: wc -c target/release/tit
diff --git a/Cargo.lock b/Cargo.lock
index abedf58271480c51384e380ef87f855e47741c0c..033fe5aa70e84f68b4fe88a6189598182ce74523 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3277,6 +3277,16 @@
 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
 
 [[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
 name = "signature"
 version = "3.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3573,6 +3583,7 @@
  "libc",
  "mio",
  "pin-project-lite",
+ "signal-hook-registry",
  "socket2",
  "tokio-macros",
  "windows-sys 0.61.2",
diff --git a/Cargo.toml b/Cargo.toml
index 6e66bc45730bafd22bde94b18022a10f11eb0729..b63fcde59c22adee6795ffdc66d0c92b4b01f545 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,7 +27,7 @@
 sha2 = { version = "0.11", default-features = false }
 ssh-key = { version = "=0.7.0-rc.11", default-features = false, features = ["ed25519", "p256", "std"] }
 thiserror = "2.0"
-tokio = { version = "1.53", default-features = false, features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
+tokio = { version = "1.53", default-features = false, features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
 tokio-stream = { version = "0.1", default-features = false }
 toml = { version = "1.1", default-features = false, features = ["parse", "serde", "std"] }
 url = { version = "2.5", features = ["serde"] }
diff --git a/README.md b/README.md
index e71fe9ba9287040d5e1635d589534a1e2bd2b6ae..5122da99df10b56a3bca876f21dffb81d84decae 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,25 @@
 cargo build --locked
 ```
 
+## Run
+
+Create the first administrator and import a bare repository before you start
+the server:
+
+```text
+tit --config /srv/tit/config.toml setup admin alice "SSH_PUBLIC_KEY"
+tit --config /srv/tit/config.toml admin repository import alice example /absolute/path/example.git
+tit --config /srv/tit/config.toml serve
+```
+
+The `serve` command starts the HTTP and SSH listeners in one process. It creates
+`ssh_host_ed25519_key` with mode 600 during the first start and uses the same
+host key during subsequent starts. Keep this file with the instance data.
+
+The server owns the instance lock until it receives SIGINT or SIGTERM. Stop the
+server before you run an offline administrator command. A subsequent milestone
+routes necessary online administrator commands through the control socket.
+
 ## Quality gate
 
 Install `cargo-deny` version 0.20.2. Then, run this command from the repository
diff --git a/docs/adr/0009-operational-server.md b/docs/adr/0009-operational-server.md
new file mode 100644
index 0000000000000000000000000000000000000000..d7f230bcee5cae3653c5b9cc41013b5db1c5704a
--- /dev/null
+++ b/docs/adr/0009-operational-server.md
@@ -1,0 +1,45 @@
+# Architectural decision record 0009: operational server
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+The Milestone 2 services had test interfaces, but the `tit` executable did not
+start them. The Milestone 2 gate requires an operator to import a repository and
+let users browse and clone it through HTTP and SSH.
+
+## Decision
+
+Add `tit serve`. Hold the exclusive instance lock for the complete server run.
+Start the Axum HTTP listener and the Russh SSH listener in one Tokio process.
+Stop both listeners after SIGINT or SIGTERM. If one listener cannot start, stop
+the listener that already started and return an error.
+
+At startup, read the active SSH public keys and the active public repositories
+from SQLite. Give the SSH transport a fixed map from each owner and repository
+slug to its immutable repository ID. Resolve the final path below the canonical
+repository directory. The instance lock prevents an offline command from
+changing this map during the server run.
+
+Create an Ed25519 SSH host key in `ssh_host_ed25519_key` during the first start.
+Use mode 600 and refuse a symbolic link, a non-file path, unsafe permissions,
+an encrypted key, a different key algorithm, and an oversized key file. Read
+the same key during subsequent starts.
+
+## Evidence
+
+The executable test creates an administrator, imports a repository, and starts
+`tit serve`. It browses and clones the managed repository through HTTP. It also
+uses the administrator key and stock Git and OpenSSH to clone through SSH. The
+test confirms that an offline command cannot acquire the instance lock. It
+sends SIGTERM, confirms a clean stop, starts the server again, and confirms that
+the SSH host key did not change.
+
+## Consequences
+
+The Web UI and both Git transports now use the repository layout that the admin
+command creates. An operator must stop the server to use an offline admin
+command. Milestone 3.1 adds the owner-only control socket for specified
+online operations.
diff --git a/scripts/check-m2-8 b/scripts/check-m2-8
index e79d489fd1ee487be46da6b7fe2698d1990b87ef..5e809e5f5fcb3d2f47d2df256bfd2f76e25ddc8c 100755
--- a/scripts/check-m2-8
+++ b/scripts/check-m2-8
@@ -4,3 +4,4 @@
 ./scripts/check
 cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
 cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats
+cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh
diff --git a/src/cli.rs b/src/cli.rs
index daefd6aa2c09aa37b6b0aa6a3777b5da37f6dbc9..4d442cbc6629fde2f860a36de08a640906b7f57a 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -46,6 +46,8 @@
 
 #[derive(Clone, Debug, Subcommand)]
 pub(crate) enum Command {
+    /// Start the HTTP and SSH servers
+    Serve,
     /// Check the instance database
     Doctor,
     /// Set up an uninitialized instance
diff --git a/src/git/transport.rs b/src/git/transport.rs
index 73d8a64ec0bbc4683551de8309e3c8db8090eba2..696df1d706f8b4e2c2cf182596881397de21b625 100644
--- a/src/git/transport.rs
+++ b/src/git/transport.rs
@@ -1,5 +1,7 @@
+use std::collections::HashMap;
 use std::fs;
 use std::path::{Path, PathBuf};
+use std::sync::Arc;
 
 use thiserror::Error;
 use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore};
@@ -9,6 +11,7 @@
 #[derive(Clone)]
 pub(crate) struct GitRepositories {
     root: PathBuf,
+    managed_public: Option<Arc<HashMap<(String, String), String>>>,
     push_database: Option<PathBuf>,
     push_jobs: std::sync::Arc<Semaphore>,
     blocking_jobs: std::sync::Arc<Semaphore>,
@@ -22,6 +25,7 @@
         })?;
         Ok(Self {
             root,
+            managed_public: None,
             push_database: None,
             push_jobs: std::sync::Arc::new(Semaphore::new(1)),
             blocking_jobs: std::sync::Arc::new(Semaphore::new(MAX_BLOCKING_GIT_JOBS)),
@@ -37,6 +41,25 @@
         Ok(repositories)
     }
 
+    pub(crate) fn new_managed_public(
+        root: &Path,
+        repositories: impl IntoIterator<Item = (String, String, String)>,
+    ) -> Result<Self, RepositoryPathError> {
+        let mut service = Self::new(root)?;
+        let mut paths = HashMap::new();
+        for (owner, repository, id) in repositories {
+            if !valid_managed_id(&id)
+                || paths
+                    .insert((owner.clone(), repository.clone()), id)
+                    .is_some()
+            {
+                return Err(RepositoryPathError::InvalidCatalog);
+            }
+        }
+        service.managed_public = Some(Arc::new(paths));
+        Ok(service)
+    }
+
     pub(crate) fn resolve(
         &self,
         owner: &str,
@@ -49,7 +72,21 @@
         if !valid_name(repository, 100) {
             return Err(RepositoryPathError::InvalidName);
         }
-        let candidate = self.root.join(owner).join(format!("{repository}.git"));
+        let candidate = match &self.managed_public {
+            Some(repositories) => {
+                let id = repositories
+                    .get(&(owner.to_owned(), repository.to_owned()))
+                    .ok_or_else(|| RepositoryPathError::Repository {
+                        path: self.root.join(owner).join(repository),
+                        source: std::io::Error::new(
+                            std::io::ErrorKind::NotFound,
+                            "repository is not public and active",
+                        ),
+                    })?;
+                self.root.join(format!("{id}.git"))
+            }
+            None => self.root.join(owner).join(format!("{repository}.git")),
+        };
         let candidate =
             fs::canonicalize(&candidate).map_err(|source| RepositoryPathError::Repository {
                 path: candidate,
@@ -131,6 +168,13 @@
     }
 }
 
+fn valid_managed_id(value: &str) -> bool {
+    value.len() == 32
+        && value
+            .bytes()
+            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
+}
+
 pub(crate) enum GitSshService {
     Upload(PathBuf),
     Receive(PathBuf),
@@ -166,6 +210,8 @@
     },
     #[error("repository resolves outside the repository root")]
     OutsideRoot,
+    #[error("managed repository catalog is not valid")]
+    InvalidCatalog,
 }
 
 #[cfg(test)]
@@ -210,5 +256,33 @@
         ] {
             assert!(repositories.resolve_ssh_command(command).is_err());
         }
+    }
+
+    #[test]
+    fn resolves_managed_public_repositories_by_immutable_id() {
+        let directory = TempDir::new().expect("create a managed repository root");
+        let id = "11111111111111111111111111111111";
+        let repository = directory.path().join(format!("{id}.git"));
+        fs::create_dir(&repository).expect("create a managed repository");
+        let repositories = GitRepositories::new_managed_public(
+            directory.path(),
+            [("alice".to_owned(), "example".to_owned(), id.to_owned())],
+        )
+        .expect("open the managed repository root");
+
+        assert_eq!(
+            repositories
+                .resolve_ssh_command(b"git-upload-pack '/alice/example.git'")
+                .expect("resolve a managed repository"),
+            fs::canonicalize(&repository).expect("canonicalize the managed repository")
+        );
+        assert!(repositories.resolve("alice", "missing").is_err());
+        assert!(matches!(
+            GitRepositories::new_managed_public(
+                directory.path(),
+                [("alice".to_owned(), "bad".to_owned(), "not-an-id".to_owned())]
+            ),
+            Err(RepositoryPathError::InvalidCatalog)
+        ));
     }
 }
diff --git a/src/main.rs b/src/main.rs
index 04feabf673b16197456184985c7f0b4eddf7af52..45180fd5587342460f1df28d15699afe14016c0e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,16 +9,14 @@
 mod config;
 mod domain;
 mod feed;
-#[allow(dead_code, reason = "M1C proves Git reads before the CLI serves them")]
+#[allow(dead_code, reason = "the server uses only part of the shared Git API")]
 mod git;
-#[allow(
-    dead_code,
-    reason = "M2 establishes the Web interface before tit serve calls it"
-)]
+#[allow(dead_code, reason = "the server uses only part of the shared HTTP API")]
 mod http;
 mod instance;
 mod markdown;
-#[allow(dead_code, reason = "M1B proves the SSH server before M2 calls it")]
+mod serve;
+#[allow(dead_code, reason = "the server uses only part of the shared SSH API")]
 mod ssh;
 mod store;
 
@@ -29,7 +27,8 @@
 
 use crate::cli::{AdminCommand, Cli, Command, ObjectFormat, RepositoryCommand, SetupCommand};
 
-fn main() -> ExitCode {
+#[tokio::main]
+async fn main() -> ExitCode {
     let cli = match Cli::try_parse() {
         Ok(cli) => cli,
         Err(error) => {
@@ -42,6 +41,13 @@
     match config::load(&cli) {
         Ok(config) => match cli.command {
             None => ExitCode::SUCCESS,
+            Some(Command::Serve) => match serve::run(&config).await {
+                Ok(()) => ExitCode::SUCCESS,
+                Err(error) => {
+                    eprintln!("tit: {error}");
+                    ExitCode::FAILURE
+                }
+            },
             Some(Command::Doctor) => match store::doctor(&config.instance_dir) {
                 Ok(()) => ExitCode::SUCCESS,
                 Err(error) => {
diff --git a/src/serve.rs b/src/serve.rs
new file mode 100644
index 0000000000000000000000000000000000000000..34a8ef64f4b7ecefd5ba03a57c28369cda4efac6
--- /dev/null
+++ b/src/serve.rs
@@ -1,0 +1,250 @@
+use std::fs::{self, File, OpenOptions};
+use std::io::{Read, Write};
+use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
+use std::path::{Path, PathBuf};
+
+use rand::rng;
+use ssh_key::{Algorithm, LineEnding, PrivateKey};
+use thiserror::Error;
+
+use crate::auth::{AuthError, SshPublicKey};
+use crate::config::{Config, ConfigError};
+use crate::git::transport::{GitRepositories, RepositoryPathError};
+use crate::http::{PublicWebConfig, RunningWebServer, WebError};
+use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
+use crate::ssh::{RunningSshServer, SshServerError};
+use crate::store::{Store, StoreError};
+
+pub(crate) async fn run(config: &Config) -> Result<(), ServeError> {
+    let _lock = InstanceLock::acquire(&config.instance_dir)?;
+    let database = prepare_database(&config.instance_dir)?;
+    let repository_root = prepare_repository_root(&config.instance_dir)?;
+    let store = Store::open(&database)?;
+    let keys = store
+        .active_ssh_public_keys()?
+        .into_iter()
+        .map(|key| SshPublicKey::parse(&key))
+        .collect::<Result<Vec<_>, _>>()?;
+    let repositories = store
+        .active_public_repositories()?
+        .into_iter()
+        .map(|repository| (repository.owner, repository.slug, repository.id));
+    let git = GitRepositories::new_managed_public(&repository_root, repositories)?;
+    drop(store);
+
+    let (http_clone_base, ssh_clone_base) = clone_bases(config)?;
+    let host_key = load_or_create_host_key(&config.instance_dir)?;
+    let web = RunningWebServer::start_public(
+        config.http_listen,
+        PublicWebConfig {
+            instance_dir: config.instance_dir.clone(),
+            http_clone_base,
+            ssh_clone_base,
+        },
+    )
+    .await?;
+    let ssh = match RunningSshServer::start_with_git_and_host_key(
+        config.ssh_listen,
+        &keys,
+        git,
+        host_key,
+    )
+    .await
+    {
+        Ok(ssh) => ssh,
+        Err(error) => {
+            web.shutdown().await?;
+            return Err(error.into());
+        }
+    };
+
+    let signal = shutdown_signal().await;
+    let (ssh_result, web_result) = tokio::join!(ssh.shutdown(), web.shutdown());
+    ssh_result?;
+    web_result?;
+    signal.map_err(ServeError::Signal)
+}
+
+const HOST_KEY_FILE: &str = "ssh_host_ed25519_key";
+const MAX_HOST_KEY_BYTES: u64 = 64 * 1024;
+
+fn load_or_create_host_key(instance_dir: &Path) -> Result<PrivateKey, ServeError> {
+    let path = instance_dir.join(HOST_KEY_FILE);
+    match fs::symlink_metadata(&path) {
+        Ok(metadata) => {
+            if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
+                return Err(ServeError::InvalidHostKeyFile(path));
+            }
+        }
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+            return create_host_key(&path);
+        }
+        Err(source) => return Err(ServeError::HostKeyIo { path, source }),
+    }
+    read_host_key(&path)
+}
+
+fn create_host_key(path: &Path) -> Result<PrivateKey, ServeError> {
+    let key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
+    let encoded = key.to_openssh(LineEnding::LF)?;
+    let mut file = OpenOptions::new()
+        .write(true)
+        .create_new(true)
+        .mode(0o600)
+        .open(path)
+        .map_err(|source| ServeError::HostKeyIo {
+            path: path.to_owned(),
+            source,
+        })?;
+    file.write_all(encoded.as_bytes())
+        .and_then(|()| file.sync_all())
+        .map_err(|source| ServeError::HostKeyIo {
+            path: path.to_owned(),
+            source,
+        })?;
+    Ok(key)
+}
+
+fn read_host_key(path: &Path) -> Result<PrivateKey, ServeError> {
+    let file = File::open(path).map_err(|source| ServeError::HostKeyIo {
+        path: path.to_owned(),
+        source,
+    })?;
+    let metadata = file.metadata().map_err(|source| ServeError::HostKeyIo {
+        path: path.to_owned(),
+        source,
+    })?;
+    let mode = metadata.permissions().mode() & 0o777;
+    if !metadata.file_type().is_file() {
+        return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
+    }
+    if mode & 0o077 != 0 {
+        return Err(ServeError::HostKeyPermissions {
+            path: path.to_owned(),
+            mode,
+        });
+    }
+    if metadata.len() > MAX_HOST_KEY_BYTES {
+        return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
+    }
+    let capacity = usize::try_from(metadata.len())
+        .map_err(|_| ServeError::InvalidHostKeyFile(path.to_owned()))?;
+    let mut encoded = Vec::with_capacity(capacity);
+    file.take(MAX_HOST_KEY_BYTES + 1)
+        .read_to_end(&mut encoded)
+        .map_err(|source| ServeError::HostKeyIo {
+            path: path.to_owned(),
+            source,
+        })?;
+    if encoded.len() as u64 > MAX_HOST_KEY_BYTES {
+        return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
+    }
+    let key = PrivateKey::from_openssh(&encoded)?;
+    if key.algorithm() != Algorithm::Ed25519 || key.is_encrypted() {
+        return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
+    }
+    Ok(key)
+}
+
+#[cfg(unix)]
+async fn shutdown_signal() -> std::io::Result<()> {
+    let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
+    tokio::select! {
+        result = tokio::signal::ctrl_c() => result,
+        _ = terminate.recv() => Ok(()),
+    }
+}
+
+#[cfg(not(unix))]
+async fn shutdown_signal() -> std::io::Result<()> {
+    tokio::signal::ctrl_c().await
+}
+
+fn clone_bases(config: &Config) -> Result<(String, String), ConfigError> {
+    let (http, ssh) = config.clone_urls("owner", "repository")?;
+    let suffix = "/owner/repository";
+    Ok((
+        http.as_str()
+            .strip_suffix(suffix)
+            .expect("the HTTP clone URL contains the supplied path")
+            .to_owned(),
+        ssh.as_str()
+            .strip_suffix(suffix)
+            .expect("the SSH clone URL contains the supplied path")
+            .to_owned(),
+    ))
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum ServeError {
+    #[error(transparent)]
+    Instance(#[from] InstanceError),
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error(transparent)]
+    Authentication(#[from] AuthError),
+    #[error(transparent)]
+    Repository(#[from] RepositoryPathError),
+    #[error(transparent)]
+    Configuration(#[from] ConfigError),
+    #[error(transparent)]
+    Web(#[from] WebError),
+    #[error(transparent)]
+    Ssh(#[from] SshServerError),
+    #[error("cannot wait for a shutdown signal: {0}")]
+    Signal(std::io::Error),
+    #[error("cannot read or write SSH host key {path}: {source}")]
+    HostKeyIo {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("SSH host key path is not a valid Ed25519 private-key file: {0}")]
+    InvalidHostKeyFile(PathBuf),
+    #[error("SSH host key permissions for {path} are {mode:o}, expected 600 or more restrictive")]
+    HostKeyPermissions { path: PathBuf, mode: u32 },
+    #[error(transparent)]
+    HostKey(#[from] ssh_key::Error),
+}
+
+#[cfg(test)]
+mod tests {
+    use std::os::unix::fs::symlink;
+
+    use tempfile::TempDir;
+
+    use super::*;
+
+    #[test]
+    fn persists_a_private_host_key_and_rejects_unsafe_replacements() {
+        let directory = TempDir::new().expect("create a host-key directory");
+        let first = load_or_create_host_key(directory.path()).expect("create a host key");
+        let path = directory.path().join(HOST_KEY_FILE);
+        assert_eq!(
+            fs::metadata(&path)
+                .expect("inspect the host key")
+                .permissions()
+                .mode()
+                & 0o777,
+            0o600
+        );
+        let second = load_or_create_host_key(directory.path()).expect("read the host key");
+        assert_eq!(first.public_key(), second.public_key());
+
+        let mut permissions = fs::metadata(&path)
+            .expect("inspect the host key")
+            .permissions();
+        permissions.set_mode(0o644);
+        fs::set_permissions(&path, permissions).expect("make the host key unsafe");
+        assert!(matches!(
+            load_or_create_host_key(directory.path()),
+            Err(ServeError::HostKeyPermissions { mode: 0o644, .. })
+        ));
+
+        fs::remove_file(&path).expect("remove the host key");
+        symlink(directory.path().join("target"), &path).expect("replace the host key with a link");
+        assert!(matches!(
+            load_or_create_host_key(directory.path()),
+            Err(ServeError::InvalidHostKeyFile(candidate)) if candidate == path
+        ));
+    }
+}
diff --git a/src/ssh.rs b/src/ssh.rs
index 456b8083197a2308d0af48838125aff25aaf38ac..07a97aa26bc90660f94f41e6b23638436dd57523 100644
--- a/src/ssh.rs
+++ b/src/ssh.rs
@@ -37,7 +37,8 @@
         address: SocketAddr,
         authorized_keys: &[SshPublicKey],
     ) -> Result<Self, SshServerError> {
-        Self::start_inner(address, authorized_keys, &[], None).await
+        let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
+        Self::start_inner(address, authorized_keys, &[], None, host_key).await
     }
 
     pub(crate) async fn start_with_git(
@@ -45,7 +46,17 @@
         authorized_keys: &[SshPublicKey],
         repositories: GitRepositories,
     ) -> Result<Self, SshServerError> {
-        Self::start_inner(address, authorized_keys, &[], Some(repositories)).await
+        let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
+        Self::start_inner(address, authorized_keys, &[], Some(repositories), host_key).await
+    }
+
+    pub(crate) async fn start_with_git_and_host_key(
+        address: SocketAddr,
+        authorized_keys: &[SshPublicKey],
+        repositories: GitRepositories,
+        host_key: PrivateKey,
+    ) -> Result<Self, SshServerError> {
+        Self::start_inner(address, authorized_keys, &[], Some(repositories), host_key).await
     }
 
     pub(crate) async fn start_with_git_writes(
@@ -64,7 +75,15 @@
         .await
         .map_err(|_| SshServerError::Join)?
         .map_err(|error| SshServerError::Recovery(error.to_string()))?;
-        Self::start_inner(address, authorized_keys, writable_keys, Some(repositories)).await
+        let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
+        Self::start_inner(
+            address,
+            authorized_keys,
+            writable_keys,
+            Some(repositories),
+            host_key,
+        )
+        .await
     }
 
     async fn start_inner(
@@ -72,10 +91,10 @@
         authorized_keys: &[SshPublicKey],
         writable_keys: &[SshPublicKey],
         repositories: Option<GitRepositories>,
+        host_key: PrivateKey,
     ) -> Result<Self, SshServerError> {
         let listener = TcpListener::bind(address).await?;
         let address = listener.local_addr()?;
-        let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
         let mut methods = MethodSet::empty();
         methods.push(MethodKind::PublicKey);
         let config = Arc::new(russh::server::Config {
diff --git a/src/store/mod.rs b/src/store/mod.rs
index 7a572023ebd535dbd949a58292166afe1aaa9ae8..596b011e44a4710cb21d45b9ae4d1284906cf037 100644
--- a/src/store/mod.rs
+++ b/src/store/mod.rs
@@ -567,6 +567,44 @@
 
     #[allow(
         dead_code,
+        reason = "some integration tests import storage without the server"
+    )]
+    pub(crate) fn active_ssh_public_keys(&self) -> Result<Vec<String>, StoreError> {
+        let mut statement = self.connection.prepare(
+            "SELECT ssh_public_key.canonical_key
+             FROM ssh_public_key
+             JOIN account ON account.id = ssh_public_key.account_id
+             WHERE account.state = 'active'
+             ORDER BY ssh_public_key.id",
+        )?;
+        statement
+            .query_map([], |row| row.get(0))?
+            .collect::<Result<Vec<_>, _>>()
+            .map_err(Into::into)
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests import storage without the server"
+    )]
+    pub(crate) fn active_public_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
+        let mut statement = self.connection.prepare(
+            "SELECT repository.id, account.username, repository.slug,
+                    repository.visibility, repository.state, repository.object_format,
+                    repository.created_at, repository.archived_at
+             FROM repository
+             JOIN account ON account.id = repository.owner_account_id
+             WHERE repository.visibility = 'public' AND repository.state = 'active'
+             ORDER BY account.username, repository.slug",
+        )?;
+        statement
+            .query_map([], repository_from_row)?
+            .collect::<Result<Vec<_>, _>>()
+            .map_err(Into::into)
+    }
+
+    #[allow(
+        dead_code,
         reason = "some integration tests import storage without public event pages"
     )]
     pub(crate) fn public_repository_events(
diff --git a/tests/serve.rs b/tests/serve.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e59a1887c84c86fbd7768c9dda90ba39d6005ba2
--- /dev/null
+++ b/tests/serve.rs
@@ -1,0 +1,286 @@
+#[allow(
+    dead_code,
+    reason = "the server test uses only part of the shared test support"
+)]
+mod support;
+
+use std::fs;
+use std::io::{Read, Write};
+use std::net::{SocketAddr, TcpStream};
+use std::os::unix::fs::PermissionsExt;
+use std::path::Path;
+use std::process::{Child, Command, Stdio};
+use std::thread;
+use std::time::{Duration, Instant};
+
+use support::{create_ssh_key_fixture, free_address};
+use tempfile::TempDir;
+
+#[test]
+fn serves_an_imported_repository_through_http_and_ssh() {
+    let instance = TempDir::new().expect("create an instance directory");
+    let http = free_address();
+    let ssh = free_address();
+    let config = instance.path().join("config.toml");
+    fs::write(
+        &config,
+        format!(
+            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
+            ssh.port()
+        ),
+    )
+    .expect("write the server configuration");
+    let private_key = instance.path().join("administrator");
+    create_ssh_key_fixture(&private_key);
+    let public_key = fs::read_to_string(private_key.with_extension("pub"))
+        .expect("read the administrator public key");
+    command(
+        instance.path(),
+        [
+            "--config",
+            config.to_str().expect("a UTF-8 configuration path"),
+            "setup",
+            "admin",
+            "alice",
+            public_key.trim(),
+        ],
+    );
+
+    let source = create_source_repository(instance.path());
+    command(
+        instance.path(),
+        [
+            "--config",
+            config.to_str().expect("a UTF-8 configuration path"),
+            "admin",
+            "repository",
+            "import",
+            "alice",
+            "example",
+            source.to_str().expect("a UTF-8 source path"),
+        ],
+    );
+
+    let mut server = spawn_server(&config);
+    wait_for_listener(http, &mut server);
+    wait_for_listener(ssh, &mut server);
+
+    let summary = http_get(http, "/alice/example");
+    assert!(summary.starts_with("HTTP/1.1 200"));
+    assert!(summary.contains("serve fixture"));
+
+    let http_clone = instance.path().join("http-clone");
+    command(
+        instance.path(),
+        [
+            "clone",
+            "-q",
+            &format!("http://{http}/alice/example.git"),
+            http_clone.to_str().expect("a UTF-8 HTTP clone path"),
+        ],
+    );
+    assert_eq!(
+        fs::read(http_clone.join("README.md")).expect("read the HTTP clone"),
+        b"serve fixture\n"
+    );
+
+    let ssh_clone = instance.path().join("ssh-clone");
+    let ssh_command = format!(
+        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
+        private_key.display()
+    );
+    let output = Command::new("git")
+        .args([
+            "clone",
+            "-q",
+            &format!("ssh://ignored@127.0.0.1:{}/alice/example.git", ssh.port()),
+        ])
+        .arg(&ssh_clone)
+        .env("GIT_SSH_COMMAND", ssh_command)
+        .output()
+        .expect("clone through the tit SSH server");
+    assert!(
+        output.status.success(),
+        "SSH clone failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    assert_eq!(
+        fs::read(ssh_clone.join("README.md")).expect("read the SSH clone"),
+        b"serve fixture\n"
+    );
+
+    let locked = Command::new(env!("CARGO_BIN_EXE_tit"))
+        .args([
+            "--config",
+            config.to_str().expect("a UTF-8 configuration path"),
+            "admin",
+            "repository",
+            "inspect",
+            "alice",
+            "example",
+        ])
+        .output()
+        .expect("run an offline command while the server owns the instance");
+    assert_eq!(locked.status.code(), Some(1));
+    assert!(String::from_utf8_lossy(&locked.stderr).contains("owns the instance lock"));
+
+    server.terminate();
+    let host_key = fs::read(instance.path().join("ssh_host_ed25519_key"))
+        .expect("read the generated SSH host key");
+    assert_eq!(
+        fs::metadata(instance.path().join("ssh_host_ed25519_key"))
+            .expect("inspect the generated SSH host key")
+            .permissions()
+            .mode()
+            & 0o777,
+        0o600
+    );
+    let mut restarted = spawn_server(&config);
+    wait_for_listener(http, &mut restarted);
+    wait_for_listener(ssh, &mut restarted);
+    assert_eq!(
+        fs::read(instance.path().join("ssh_host_ed25519_key"))
+            .expect("read the reused SSH host key"),
+        host_key
+    );
+    restarted.terminate();
+}
+
+fn spawn_server(config: &Path) -> ChildGuard {
+    let child = Command::new(env!("CARGO_BIN_EXE_tit"))
+        .args([
+            "--config",
+            config.to_str().expect("a UTF-8 configuration path"),
+            "serve",
+        ])
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .spawn()
+        .expect("start the tit server");
+    ChildGuard(Some(child))
+}
+
+fn create_source_repository(parent: &Path) -> std::path::PathBuf {
+    let worktree = parent.join("source-worktree");
+    command(
+        parent,
+        [
+            "init",
+            "-q",
+            "-b",
+            "main",
+            worktree.to_str().expect("a UTF-8 worktree path"),
+        ],
+    );
+    fs::write(worktree.join("README.md"), b"serve fixture\n").expect("write source content");
+    command(&worktree, ["add", "."]);
+    let output = Command::new("git")
+        .args(["commit", "-q", "-m", "initial"])
+        .env("GIT_AUTHOR_NAME", "Tit Test")
+        .env("GIT_AUTHOR_EMAIL", "tit@example.test")
+        .env("GIT_COMMITTER_NAME", "Tit Test")
+        .env("GIT_COMMITTER_EMAIL", "tit@example.test")
+        .env("GIT_CONFIG_COUNT", "1")
+        .env("GIT_CONFIG_KEY_0", "commit.gpgsign")
+        .env("GIT_CONFIG_VALUE_0", "false")
+        .current_dir(&worktree)
+        .output()
+        .expect("commit source content");
+    assert!(output.status.success(), "Git commit failed");
+    let bare = parent.join("source.git");
+    command(
+        parent,
+        [
+            "clone",
+            "-q",
+            "--bare",
+            worktree.to_str().expect("a UTF-8 worktree path"),
+            bare.to_str().expect("a UTF-8 bare path"),
+        ],
+    );
+    bare
+}
+
+fn command<const N: usize>(directory: &Path, arguments: [&str; N]) {
+    let executable = if matches!(arguments.first(), Some(&"--config")) {
+        env!("CARGO_BIN_EXE_tit")
+    } else {
+        "git"
+    };
+    let output = Command::new(executable)
+        .args(arguments)
+        .current_dir(directory)
+        .output()
+        .expect("run a fixture command");
+    assert!(
+        output.status.success(),
+        "fixture command failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+}
+
+fn wait_for_listener(address: SocketAddr, server: &mut ChildGuard) {
+    let deadline = Instant::now() + Duration::from_secs(10);
+    loop {
+        if TcpStream::connect(address).is_ok() {
+            return;
+        }
+        if let Some(status) = server
+            .0
+            .as_mut()
+            .expect("a running server")
+            .try_wait()
+            .expect("check the server")
+        {
+            panic!("tit serve stopped early with {status}");
+        }
+        assert!(
+            Instant::now() < deadline,
+            "listener {address} did not start"
+        );
+        thread::sleep(Duration::from_millis(20));
+    }
+}
+
+fn http_get(address: SocketAddr, path: &str) -> String {
+    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
+    write!(
+        stream,
+        "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n"
+    )
+    .expect("write an HTTP request");
+    let mut response = String::new();
+    stream
+        .read_to_string(&mut response)
+        .expect("read an HTTP response");
+    response
+}
+
+struct ChildGuard(Option<Child>);
+
+impl ChildGuard {
+    fn terminate(&mut self) {
+        if let Some(mut child) = self.0.take() {
+            let signal = Command::new("kill")
+                .args(["-TERM", &child.id().to_string()])
+                .output()
+                .expect("send SIGTERM to the tit server");
+            assert!(signal.status.success(), "cannot send SIGTERM");
+            let status = child.wait().expect("wait for the tit server");
+            assert!(status.success(), "tit serve did not stop cleanly: {status}");
+        }
+    }
+
+    fn stop(&mut self) {
+        if let Some(mut child) = self.0.take() {
+            child.kill().expect("stop the tit server");
+            child.wait().expect("wait for the tit server");
+        }
+    }
+}
+
+impl Drop for ChildGuard {
+    fn drop(&mut self) {
+        self.stop();
+    }
+}
