diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,26 @@
 # Changelog
 
+## 0.3.2.1 — 2026-03-18
+
+- Replace partial `decodeUtf8` with total `decodeLatin1` in `Base64.encode`
+  and `Signing.sign` — provably safe on base64 ASCII output, eliminates last
+  partial function usage
+- Fix redundant `T.breakOn` call in `NarInfo.parseLine`
+- No API changes
+
 ## 0.3.2.0 — 2026-03-18
 
+### Server logging
+
+- **Request logging** — Apache Combined format via `wai-extra` middleware,
+  configurable with `LOG_REQUESTS=0` to disable.
+- **Error logging** — auth rejections, validation failures, oversized uploads,
+  and bad paths now logged to stderr with request method and path context.
+- **`withLimitedBody` combinator** — extracted common body-limiting pattern from
+  PUT handlers, reducing duplication.
+- **`notFound` named constant** — replaces three inline 404 responses.
+- Fixed `loadSigningKey` warnings going to stdout instead of stderr.
+
 ### Seed action fixes
 
 - **Resolve runtime outputs, not derivations** — `nix-instantiate` replaced
@@ -14,7 +33,7 @@
 - **Fix xargs line-too-long** — large path lists passed via file instead of inline.
 - **Diagnostic output** — upload failure HTTP codes now printed; `nix copy` errors
   no longer silenced.
-- **Parallelism** — default reduced from 32 to 8; per-upload `--max-time` added.
+- **Parallelism** — per-upload `--max-time` added (120s for NARs, 30s for narinfos).
 
 ### Server validation
 
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -230,6 +230,7 @@
 | `NIX_CACHE_DIR` | Store directory (default: `./nix-cache`) |
 | `CACHE_API_KEY` | Bearer token required for PUT requests. Omit for open writes. |
 | `SIGNING_KEY_FILE` | Path to Ed25519 secret key file for server-side narinfo signing. |
+| `LOG_REQUESTS` | Set to `0` to disable request logging. Enabled by default (Apache Combined format). |
 
 ### Endpoints
 
@@ -403,7 +404,7 @@
 | `cache-url` | yes | Base URL of the nova-cache server |
 | `api-key` | yes | Bearer token for authenticating uploads |
 | `paths` | no | Explicit store paths (space-separated). Defaults to all paths from `shell.nix` / `default.nix` |
-| `parallel` | no | Max concurrent uploads (default: 8) |
+| `parallel` | no | Max concurrent uploads (default: 32) |
 
 The action resolves runtime store paths via `nix-build`, diffs against the server's `GET /narinfo-hashes`, exports only missing paths, and uploads narinfo + NAR files in parallel. Includes per-upload timeouts, failure diagnostics, and a round-trip validation check. Works with any CI that has Nix installed.
 
diff --git a/exe/Main.hs b/exe/Main.hs
--- a/exe/Main.hs
+++ b/exe/Main.hs
@@ -3,6 +3,7 @@
 import Data.Bifunctor (first)
 import Data.ByteArray (constEq)
 import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as BS8
 import qualified Data.ByteString.Lazy as BL
 import Data.Maybe (fromMaybe)
 import Data.Text (Text)
@@ -23,6 +24,7 @@
     strictRequestBody,
   )
 import qualified Network.Wai.Handler.Warp as Warp
+import Network.Wai.Middleware.RequestLogger (logStdout)
 import NovaCache.NarInfo (NarInfo (..), parseNarInfo, renderNarInfo)
 import NovaCache.Signing (SecretKey, parseSecretKey, sign)
 import NovaCache.Store (FileStore, getCacheInfo, listNarInfoHashes, newFileStore, readNar, readNarInfo, writeNar, writeNarInfo)
@@ -51,6 +53,10 @@
 signingKeyEnvVar :: String
 signingKeyEnvVar = "SIGNING_KEY_FILE"
 
+-- | Environment variable to disable request logging.
+requestLogEnvVar :: String
+requestLogEnvVar = "LOG_REQUESTS"
+
 -- | Maximum allowed request body size (100 MB).
 maxBodySize :: Int
 maxBodySize = 100 * 1024 * 1024
@@ -77,6 +83,7 @@
   storeEnv <- lookupEnv "NIX_CACHE_DIR"
   apiKeyEnv <- lookupEnv apiKeyEnvVar
   sigKeyPath <- lookupEnv signingKeyEnvVar
+  logRequestsEnv <- lookupEnv requestLogEnvVar
 
   let port = case args of
         ("--port" : p : _) -> fromMaybe defaultPort (readMaybe p)
@@ -95,11 +102,15 @@
             cfgSigningKey = sigKey
           }
 
+  let logRequests = logRequestsEnv /= Just "0"
+      requestLogger = if logRequests then logStdout else id
+
   putStrLn ("nova-cache-server listening on port " ++ show port)
   putStrLn ("store root: " ++ storeRoot)
   putStrLn ("signing: " ++ maybe "disabled" (const "enabled") sigKey)
   putStrLn ("write auth: " ++ maybe "disabled (open writes!)" (const "enabled") (cfgApiKey cfg))
-  Warp.run port (app cfg)
+  putStrLn ("request logging: " ++ if logRequests then "enabled" else "disabled (LOG_REQUESTS=0)")
+  Warp.run port (requestLogger (app cfg))
 
 -- | Load a signing key from a file, if configured.
 loadSigningKey :: Maybe FilePath -> IO (Maybe SecretKey)
@@ -108,7 +119,7 @@
   raw <- BS.readFile path
   case first show (TE.decodeUtf8' raw) >>= parseSecretKey . T.strip of
     Left err -> do
-      putStrLn ("WARNING: failed to load signing key: " ++ err)
+      hPutStrLn stderr ("WARNING: failed to load signing key: " ++ err)
       pure Nothing
     Right sk -> pure (Just sk)
 
@@ -135,7 +146,7 @@
           Just content ->
             respond (responseLBS HTTP.status200 narInfoHeaders (BL.fromStrict content))
           Nothing ->
-            respond (responseLBS HTTP.status404 textHeaders "not found")
+            respond notFound
   -- GET /nar/<file>
   ("GET", ["nar", fileName]) -> do
     result <- readNar (cfgStore cfg) fileName
@@ -143,39 +154,37 @@
       Just content ->
         respond (responseLBS HTTP.status200 octetHeaders (BL.fromStrict content))
       Nothing ->
-        respond (responseLBS HTTP.status404 textHeaders "not found")
+        respond notFound
   -- PUT /<hash>.narinfo (auth required, validated)
   ("PUT", [hashNarinfo])
     | Just hashKey <- T.stripSuffix ".narinfo" hashNarinfo ->
-        requireAuth cfg req respond $ do
-          bodyResult <- readBodyLimited req
-          case bodyResult of
-            Nothing ->
-              respond (responseLBS HTTP.status413 textHeaders "request body too large")
-            Just body -> case decodeAndValidate body of
-              Left err ->
+        requireAuth cfg req respond $
+          withLimitedBody req respond $ \body ->
+            case decodeAndValidate body of
+              Left err -> do
+                logWarn req ("INVALID: " <> T.unpack err)
                 respond (badRequest err)
               Right ni -> do
                 signed <- signNarInfo (cfgSigningKey cfg) ni
                 ok <- writeNarInfo (cfgStore cfg) hashKey signed
                 if ok
                   then respond (responseLBS HTTP.status200 textHeaders "ok")
-                  else respond (badRequest "invalid path")
+                  else do
+                    logWarn req "BADPATH"
+                    respond (badRequest "invalid path")
   -- PUT /nar/<file> (auth required)
   ("PUT", ["nar", fileName]) ->
-    requireAuth cfg req respond $ do
-      bodyResult <- readBodyLimited req
-      case bodyResult of
-        Nothing ->
-          respond (responseLBS HTTP.status413 textHeaders "request body too large")
-        Just body -> do
-          ok <- writeNar (cfgStore cfg) fileName body
-          if ok
-            then respond (responseLBS HTTP.status200 textHeaders "ok")
-            else respond (badRequest "invalid path")
+    requireAuth cfg req respond $
+      withLimitedBody req respond $ \body -> do
+        ok <- writeNar (cfgStore cfg) fileName body
+        if ok
+          then respond (responseLBS HTTP.status200 textHeaders "ok")
+          else do
+            logWarn req "BADPATH"
+            respond (badRequest "invalid path")
   -- Fallback
   _ ->
-    respond (responseLBS HTTP.status404 textHeaders "not found")
+    respond notFound
 
 -- ---------------------------------------------------------------------------
 -- Validation pipeline
@@ -209,6 +218,16 @@
       then pure Nothing
       else pure (Just body)
 
+-- | Run an action with the limited request body, responding 413 if too large.
+withLimitedBody :: Request -> (Response -> IO ResponseReceived) -> (BS.ByteString -> IO ResponseReceived) -> IO ResponseReceived
+withLimitedBody req respond action = do
+  bodyResult <- readBodyLimited req
+  case bodyResult of
+    Nothing -> do
+      logWarn req "OVERLIMIT"
+      respond (responseLBS HTTP.status413 textHeaders "request body too large")
+    Just body -> action body
+
 -- ---------------------------------------------------------------------------
 -- Auth
 -- ---------------------------------------------------------------------------
@@ -226,7 +245,9 @@
         expectedHeader = "Bearer " <> expected
      in if maybe False (constEq expectedHeader) provided
           then action
-          else respond (responseLBS HTTP.status401 textHeaders "unauthorized")
+          else do
+            logWarn req "REJECTED"
+            respond (responseLBS HTTP.status401 textHeaders "unauthorized")
 
 -- ---------------------------------------------------------------------------
 -- Signing
@@ -251,6 +272,20 @@
 renderNarInfoBytes = TE.encodeUtf8 . renderNarInfo
 
 -- ---------------------------------------------------------------------------
+-- Logging
+-- ---------------------------------------------------------------------------
+
+-- | Log a server-side warning to stderr with request context.
+logWarn :: Request -> String -> IO ()
+logWarn req msg =
+  hPutStrLn stderr $
+    msg
+      <> " "
+      <> BS8.unpack (requestMethod req)
+      <> " /"
+      <> T.unpack (T.intercalate "/" (pathInfo req))
+
+-- ---------------------------------------------------------------------------
 -- Response helpers
 -- ---------------------------------------------------------------------------
 
@@ -269,6 +304,10 @@
 boolText :: Bool -> Text
 boolText True = "1"
 boolText False = "0"
+
+-- | 404 Not Found response.
+notFound :: Response
+notFound = responseLBS HTTP.status404 textHeaders "not found"
 
 -- | 400 Bad Request with a text error message.
 badRequest :: Text -> Response
diff --git a/nova-cache.cabal b/nova-cache.cabal
--- a/nova-cache.cabal
+++ b/nova-cache.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               nova-cache
-version:            0.3.2.0
+version:            0.3.2.1
 synopsis:           Pure Nix binary cache protocol library
 description:
   A focused, minimal, pure-first library implementing the full Nix binary
@@ -92,6 +92,7 @@
     , http-types          >= 0.12 && < 0.13
     , text                >= 2.0 && < 2.2
     , wai                 >= 3.2 && < 3.3
+    , wai-extra           >= 3.1 && < 3.2
     , warp                >= 3.3 && < 3.5
 
 test-suite nova-cache-test
diff --git a/src/NovaCache/Base64.hs b/src/NovaCache/Base64.hs
--- a/src/NovaCache/Base64.hs
+++ b/src/NovaCache/Base64.hs
@@ -15,7 +15,7 @@
 
 -- | Encode bytes to base64 'Text'.
 encode :: ByteString -> Text
-encode = TE.decodeUtf8 . B64.encode
+encode = TE.decodeLatin1 . B64.encode
 
 -- | Decode base64 'Text' to bytes.
 decode :: Text -> Either String ByteString
diff --git a/src/NovaCache/NarInfo.hs b/src/NovaCache/NarInfo.hs
--- a/src/NovaCache/NarInfo.hs
+++ b/src/NovaCache/NarInfo.hs
@@ -135,9 +135,7 @@
 parseLine line = case T.breakOn kvSeparator line of
   (_, rest)
     | T.null rest -> Nothing
-    | otherwise -> Just (key, T.drop kvSeparatorLen rest)
-    where
-      key = fst (T.breakOn kvSeparator line)
+  (key, rest) -> Just (key, T.drop kvSeparatorLen rest)
 
 -- | Render a key-value pair.
 kv :: Text -> Text -> Text
diff --git a/src/NovaCache/Signing.hs b/src/NovaCache/Signing.hs
--- a/src/NovaCache/Signing.hs
+++ b/src/NovaCache/Signing.hs
@@ -148,7 +148,7 @@
   let pk = Ed25519.toPublic sk
       msg = TE.encodeUtf8 (fingerprint ni)
       sig = Ed25519.sign sk pk msg
-      sigB64 = TE.decodeUtf8 (B64.encode (convert sig))
+      sigB64 = TE.decodeLatin1 (B64.encode (convert sig))
   pure (keyName <> keySeparator <> sigB64)
 
 -- | Verify a @keyname:base64sig@ signature against a narinfo and public key.
diff --git a/src/NovaCache/Store.hs b/src/NovaCache/Store.hs
--- a/src/NovaCache/Store.hs
+++ b/src/NovaCache/Store.hs
@@ -19,6 +19,7 @@
   )
 where
 
+import Control.Exception (SomeException, catch, onException)
 import Data.ByteString (ByteString)
 import qualified Data.ByteString as BS
 import Data.Text (Text)
@@ -27,8 +28,11 @@
   ( createDirectoryIfMissing,
     doesFileExist,
     listDirectory,
+    removeFile,
+    renameFile,
   )
-import System.FilePath ((</>))
+import System.FilePath (takeDirectory, (</>))
+import System.IO (hClose, openBinaryTempFile)
 
 -- ---------------------------------------------------------------------------
 -- Types
@@ -64,6 +68,10 @@
 defaultStoreDir :: Text
 defaultStoreDir = "/nix/store"
 
+-- | Prefix for temporary files used during atomic writes.
+tempFilePrefix :: String
+tempFilePrefix = ".nova.tmp"
+
 -- ---------------------------------------------------------------------------
 -- Initialization
 -- ---------------------------------------------------------------------------
@@ -96,11 +104,13 @@
 
 -- | Write a narinfo by its store path hash.
 --
+-- Uses atomic write-to-temp-then-rename so concurrent readers never
+-- see partial content.
 -- Returns 'False' for path components containing traversal sequences.
 writeNarInfo :: FileStore -> Text -> ByteString -> IO Bool
 writeNarInfo fs hashKey body = case sanitizePath hashKey of
   Nothing -> pure False
-  Just safe -> BS.writeFile (fsRoot fs </> narinfoSubdir </> safe) body >> pure True
+  Just safe -> atomicWriteFile (fsRoot fs </> narinfoSubdir </> safe) body >> pure True
 
 -- ---------------------------------------------------------------------------
 -- NAR operations
@@ -116,11 +126,13 @@
 
 -- | Write a NAR file by its filename.
 --
+-- Uses atomic write-to-temp-then-rename so concurrent readers never
+-- see partial content.
 -- Returns 'False' for path components containing traversal sequences.
 writeNar :: FileStore -> Text -> ByteString -> IO Bool
 writeNar fs fileName body = case sanitizePath fileName of
   Nothing -> pure False
-  Just safe -> BS.writeFile (fsRoot fs </> narSubdir </> safe) body >> pure True
+  Just safe -> atomicWriteFile (fsRoot fs </> narSubdir </> safe) body >> pure True
 
 -- ---------------------------------------------------------------------------
 -- Listing
@@ -129,8 +141,11 @@
 -- | List all narinfo hashes currently stored.
 --
 -- Returns the filenames in the @narinfo/@ subdirectory as 'Text' values.
+-- Filters out temporary files from in-progress atomic writes.
 listNarInfoHashes :: FileStore -> IO [Text]
-listNarInfoHashes fs = map T.pack <$> listDirectory (fsRoot fs </> narinfoSubdir)
+listNarInfoHashes fs =
+  filter (not . T.isPrefixOf ".") . map T.pack
+    <$> listDirectory (fsRoot fs </> narinfoSubdir)
 
 -- ---------------------------------------------------------------------------
 -- Cache metadata
@@ -161,6 +176,33 @@
 -- ---------------------------------------------------------------------------
 -- Internal
 -- ---------------------------------------------------------------------------
+
+-- | Write a file atomically via write-to-temp-then-rename.
+--
+-- Writes to a temporary file in the same directory, then renames to
+-- the target path. On POSIX, @rename@ is atomic — readers see either
+-- the old content or the new content, never a partial write.
+-- Cleans up the temporary file on failure.
+atomicWriteFile :: FilePath -> ByteString -> IO ()
+atomicWriteFile target content = do
+  let dir = takeDirectory target
+  (tmpPath, h) <- openBinaryTempFile dir tempFilePrefix
+  let cleanup = do
+        ignoringExceptions (hClose h)
+        ignoringExceptions (removeFile tmpPath)
+  ( do
+      BS.hPut h content
+      hClose h
+      renameFile tmpPath target
+    )
+    `onException` cleanup
+
+-- | Swallow all exceptions from a cleanup action.
+ignoringExceptions :: IO () -> IO ()
+ignoringExceptions action = action `catch` silenceException
+  where
+    silenceException :: SomeException -> IO ()
+    silenceException _ = pure ()
 
 -- | Read a file if it exists, returning 'Nothing' otherwise.
 readIfExists :: FilePath -> IO (Maybe ByteString)
