diff --git a/Cargo.lock b/Cargo.lock
index eb641a2..c46f205 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -300,6 +300,7 @@ dependencies = [
  "sd-notify",
  "serde",
  "serde-aux",
+ "serde-inline-default",
  "serde_json",
  "tokio",
  "tokio-stream",
@@ -1455,6 +1456,17 @@ dependencies = [
  "serde_json",
 ]
 
+[[package]]
+name = "serde-inline-default"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9980133dc534d02ab08df3b384295223a45090c40a4c46240e3eaa982b495910"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "serde_derive"
 version = "1.0.209"
diff --git a/blahd/Cargo.toml b/blahd/Cargo.toml
index f4ab712..77d4399 100644
--- a/blahd/Cargo.toml
+++ b/blahd/Cargo.toml
@@ -26,3 +26,4 @@ uuid = { version = "1", features = ["v4"] }
 
 blah = { path = "..", features = ["rusqlite"] }
 basic-toml = "0.1.9"
+serde-inline-default = "0.2.0"
diff --git a/blahd/config.example.toml b/blahd/config.example.toml
index a38667f..c5b5ca3 100644
--- a/blahd/config.example.toml
+++ b/blahd/config.example.toml
@@ -1,13 +1,29 @@
 [database]
+# (Required)
 # The path to the main SQLite database.
 # It will be created and initialized if not exist.
 path = "/path/to/db.sqlite"
 
 [server]
 
+# (Required)
 # The socket address to listen on.
 listen = "localhost:8080"
 
+# (Required)
 # The global absolute URL prefix where this service is hosted.
 # It is for link generation and must not have trailing slash.
 base_url = "http://localhost:8080"
+
+# Maximum number of items in a single response, eg. get chat items.
+# More items will be paged.
+max_page_len = 1024
+
+# Maximum request body length in bytes.
+max_request_len = 4096
+
+# Maximum length of a single event queue.
+event_queue_len = 1024
+
+# The maximum timestamp tolerence in seconds for request validation.
+timestamp_tolerence_secs = 90
diff --git a/blahd/src/config.rs b/blahd/src/config.rs
index bd5f028..b8b672b 100644
--- a/blahd/src/config.rs
+++ b/blahd/src/config.rs
@@ -2,6 +2,7 @@ use std::path::PathBuf;
 
 use anyhow::{ensure, Result};
 use serde::Deserialize;
+use serde_inline_default::serde_inline_default;
 
 #[derive(Debug, Clone, Deserialize)]
 #[serde(deny_unknown_fields)]
@@ -16,11 +17,22 @@ pub struct DatabaseConfig {
     pub path: PathBuf,
 }
 
+#[serde_inline_default]
 #[derive(Debug, Clone, Deserialize)]
 #[serde(deny_unknown_fields)]
 pub struct ServerConfig {
     pub listen: String,
     pub base_url: String,
+
+    #[serde_inline_default(1024)]
+    pub max_page_len: usize,
+    #[serde_inline_default(4096)] // 4KiB
+    pub max_request_len: usize,
+    #[serde_inline_default(1024)]
+    pub event_queue_len: usize,
+
+    #[serde_inline_default(90)]
+    pub timestamp_tolerence_secs: u64,
 }
 
 impl Config {
diff --git a/blahd/src/main.rs b/blahd/src/main.rs
index 20963a1..de94cb5 100644
--- a/blahd/src/main.rs
+++ b/blahd/src/main.rs
@@ -26,11 +26,6 @@ use tokio_stream::StreamExt;
 use utils::ExpiringSet;
 use uuid::Uuid;
 
-const PAGE_LEN: usize = 64;
-const EVENT_QUEUE_LEN: usize = 1024;
-const MAX_BODY_LEN: usize = 4 << 10; // 4KiB
-const TIMESTAMP_TOLERENCE: u64 = 90;
-
 #[macro_use]
 mod middleware;
 mod config;
@@ -105,7 +100,9 @@ impl AppState {
         Ok(Self {
             conn: Mutex::new(conn),
             room_listeners: Mutex::new(HashMap::new()),
-            used_nonces: Mutex::new(ExpiringSet::new(Duration::from_secs(TIMESTAMP_TOLERENCE))),
+            used_nonces: Mutex::new(ExpiringSet::new(Duration::from_secs(
+                config.server.timestamp_tolerence_secs,
+            ))),
 
             config,
         })
@@ -124,7 +121,7 @@ impl AppState {
             .expect("after UNIX epoch")
             .as_secs()
             .abs_diff(data.signee.timestamp);
-        if timestamp_diff > TIMESTAMP_TOLERENCE {
+        if timestamp_diff > self.config.server.timestamp_tolerence_secs {
             return Err(error_response!(
                 StatusCode::BAD_REQUEST,
                 "invalid_timestamp",
@@ -161,7 +158,9 @@ async fn main_async(st: AppState) -> Result<()> {
         .with_state(st.clone())
         // NB. This comes at last (outmost layer), so inner errors will still be wraped with
         // correct CORS headers.
-        .layer(tower_http::limit::RequestBodyLimitLayer::new(MAX_BODY_LEN))
+        .layer(tower_http::limit::RequestBodyLimitLayer::new(
+            st.config.server.max_request_len,
+        ))
         .layer(tower_http::cors::CorsLayer::permissive());
 
     let listener = tokio::net::TcpListener::bind(&st.config.server.listen)
@@ -276,8 +275,7 @@ async fn room_get_item(
     WithRejection(params, _): WithRejection<Query<GetRoomItemParams>, ApiError>,
     OptionalAuth(user): OptionalAuth,
 ) -> Result<impl IntoResponse, ApiError> {
-    let (room_meta, items) =
-        query_room_items(&st.conn.lock().unwrap(), ruuid, user.as_ref(), &params)?;
+    let (room_meta, items) = query_room_items(&st, ruuid, user.as_ref(), &params)?;
 
     // TODO: This format is to-be-decided. Or do we even need this interface other than
     // `feed.json`?
@@ -289,7 +287,7 @@ async fn room_get_feed(
     WithRejection(Path(ruuid), _): WithRejection<Path<Uuid>, ApiError>,
     params: Query<GetRoomItemParams>,
 ) -> Result<impl IntoResponse, ApiError> {
-    let (room_meta, items) = query_room_items(&st.conn.lock().unwrap(), ruuid, None, &params)?;
+    let (room_meta, items) = query_room_items(&st, ruuid, None, &params)?;
 
     let items = items
         .into_iter()
@@ -314,7 +312,7 @@ async fn room_get_feed(
 
     let base_url = &st.config.server.base_url;
     let feed_url = format!("{base_url}/room/{ruuid}/feed.json");
-    let next_url = (items.len() == PAGE_LEN).then(|| {
+    let next_url = (items.len() == st.config.server.max_page_len).then(|| {
         let last_id = &items.last().expect("page size is not 0").id;
         format!("{feed_url}?before_id={last_id}")
     });
@@ -401,12 +399,14 @@ fn get_room_if_readable<T>(
 }
 
 fn query_room_items(
-    conn: &rusqlite::Connection,
+    st: &AppState,
     ruuid: Uuid,
     user: Option<&UserKey>,
     params: &GetRoomItemParams,
 ) -> Result<(RoomMetadata, Vec<(u64, ChatItem)>), ApiError> {
-    let (rid, title, attrs) = get_room_if_readable(conn, ruuid, user, |row| {
+    let conn = st.conn.lock().unwrap();
+
+    let (rid, title, attrs) = get_room_if_readable(&conn, ruuid, user, |row| {
         Ok((
             row.get::<_, u64>("rid")?,
             row.get::<_, String>("title")?,
@@ -432,7 +432,7 @@ fn query_room_items(
             named_params! {
                 ":rid": rid,
                 ":before_cid": params.before_id,
-                ":limit": PAGE_LEN,
+                ":limit": st.config.server.max_page_len,
             },
             |row| {
                 let cid = row.get::<_, u64>("cid")?;
@@ -543,7 +543,7 @@ async fn room_event(
     let rx = match st.room_listeners.lock().unwrap().entry(rid) {
         Entry::Occupied(ent) => ent.get().subscribe(),
         Entry::Vacant(ent) => {
-            let (tx, rx) = broadcast::channel(EVENT_QUEUE_LEN);
+            let (tx, rx) = broadcast::channel(st.config.server.event_queue_len);
             ent.insert(tx);
             rx
         }