diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,19 @@
 
 ## [Unreleased]
 
+## [1.14.0] - 2026-11-05
+
+### Added
+
+- api: Add OpenAPI 3.0 schemas for API payloads, streaming responses, and URL parameters.
+
+### Fixed
+
+- client: time out stalled connections and responses, including TLS handshakes
+- client: handle SIGINT, SIGTERM and SIGHUP during commands and daemon startup
+- push: preserve unlimited time for multipart upload completion with finite default HTTP timeouts
+- deploy: report HTTP WebSocket handshake rejections
+
 ## [1.13.0] - 2026-10-21
 
 ### Added
diff --git a/cachix.cabal b/cachix.cabal
--- a/cachix.cabal
+++ b/cachix.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.2
 name:               cachix
-version:            1.13.0
+version:            1.14.0
 synopsis:
   Command-line client for Nix binary cache hosting https://cachix.org
 
@@ -267,6 +267,7 @@
   main-is:            Main.hs
   hs-source-dirs:     test
   other-modules:
+    ClientSpec
     CommandConfigSpec
     CommandListSpec
     Daemon.NarinfoQuerySpec
@@ -276,12 +277,15 @@
     Daemon.TaskQueueSpec
     Daemon.TTLCacheSpec
     DeploySpec
+    EnvSpec
     InstallationModeSpec
     NetRcSpec
     NixConfSpec
     NixVersionSpec
     RetrySpec
+    ServantSpec
     Spec
+    StalledServer
     TerminalSpec
     URISpec
 
@@ -299,17 +303,26 @@
     , hercules-ci-cnix-store
     , here
     , hspec
+    , http-client
+    , http-client-tls
     , http-types
     , katip
+    , network
+    , process
     , protolude
     , retry
+    , servant
     , servant-auth-client
+    , servant-client
     , servant-client-core
     , stm
     , temporary
     , time
+    , unix
     , unliftio
     , uuid
     , versions
 
-  build-tool-depends: hspec-discover:hspec-discover
+  build-tool-depends:
+    , cachix:cachix
+    , hspec-discover:hspec-discover
diff --git a/src/Cachix/Client.hs b/src/Cachix/Client.hs
--- a/src/Cachix/Client.hs
+++ b/src/Cachix/Client.hs
@@ -21,6 +21,7 @@
 import Cachix.Deploy.Agent qualified as AgentCommand
 import Cachix.Deploy.OptionsParser qualified as DeployOptions
 import Hercules.CNix qualified as CNix
+import Hercules.CNix.Util qualified as CNix.Util
 import Protolude
 import System.Console.AsciiProgress (displayConsoleRegions)
 import System.Posix.Signals qualified as Signal
@@ -62,6 +63,20 @@
 -- | Install client-wide signal handlers.
 installSignalHandlers :: IO ()
 installSignalHandlers = do
+  -- CNix drops its Haskell interrupt callback. Interrupt both Nix and the
+  -- command thread so signals terminate work in either runtime.
+  mainThreadId <- myThreadId
+  for_ [Signal.sigINT, Signal.sigTERM, Signal.sigHUP] $ \signal ->
+    Signal.installHandler
+      signal
+      ( Signal.Catch $ do
+          CNix.Util.triggerInterrupt
+          -- Use an asynchronous exception so HTTP clients do not wrap it as
+          -- a connection failure and retry the interrupted request.
+          throwTo mainThreadId UserInterrupt
+      )
+      Nothing
+
   -- Ignore sigPIPE.
   -- The default handler terminates the process.
   -- WARN: may be reset to SIG_DFL when initializing the Nix library.
diff --git a/src/Cachix/Client/Env.hs b/src/Cachix/Client/Env.hs
--- a/src/Cachix/Client/Env.hs
+++ b/src/Cachix/Client/Env.hs
@@ -17,7 +17,7 @@
   ( ManagerSettings,
     managerModifyRequest,
     managerResponseTimeout,
-    responseTimeoutNone,
+    responseTimeoutMicro,
   )
 import Network.HTTP.Client.TLS (newTlsManagerWith, tlsManagerSettings)
 import Network.HTTP.Simple (setRequestHeader)
@@ -60,7 +60,8 @@
 customManagerSettings :: ManagerSettings
 customManagerSettings =
   tlsManagerSettings
-    { managerResponseTimeout = responseTimeoutNone,
+    { -- This also bounds connection acquisition, including the TLS handshake.
+      managerResponseTimeout = responseTimeoutMicro (30 * 1_000_000),
       -- managerModifyRequest :: Request -> IO Request
       managerModifyRequest = return . setRequestHeader "User-Agent" [toS cachixVersion]
     }
diff --git a/src/Cachix/Client/Push.hs b/src/Cachix/Client/Push.hs
--- a/src/Cachix/Client/Push.hs
+++ b/src/Cachix/Client/Push.hs
@@ -77,6 +77,7 @@
 import Hercules.CNix.Std.Set qualified as Std.Set
 import Hercules.CNix.Store (Store)
 import Hercules.CNix.Store qualified as Store
+import Network.HTTP.Client (responseTimeoutNone)
 import Network.HTTP.Types (status401, status404)
 import Nix.NarInfo qualified as NarInfo
 import Protolude hiding (toS)
@@ -274,7 +275,8 @@
               Multipart.narInfoCreate = nic
             }
 
-  liftIO $ void $ retryHttp $ withClientM completeMultipartUploadRequest clientEnv escalate
+  -- Assembling a large multipart upload can take several minutes.
+  liftIO $ void $ retryHttp $ withClientM (withResponseTimeout responseTimeoutNone completeMultipartUploadRequest) clientEnv escalate
 
 data UploadNarDetails = UploadNarDetails
   { undNarSize :: Integer,
diff --git a/src/Cachix/Client/Retry.hs b/src/Cachix/Client/Retry.hs
--- a/src/Cachix/Client/Retry.hs
+++ b/src/Cachix/Client/Retry.hs
@@ -43,8 +43,8 @@
 
 -- Policies
 
--- | Per-HTTP-request policy. Wall-time bounded so the user-visible delay is
--- predictable regardless of how many attempts fit. Jittered exponential
+-- | Per-HTTP-request policy. Bounds cumulative backoff delays, excluding time
+-- spent executing requests. Jittered exponential
 -- backoff to avoid thundering-herd on shared origins (CDN incidents).
 httpRetryPolicy :: (MonadIO m) => RetryPolicyM m
 httpRetryPolicy =
diff --git a/src/Cachix/Client/Servant.hs b/src/Cachix/Client/Servant.hs
--- a/src/Cachix/Client/Servant.hs
+++ b/src/Cachix/Client/Servant.hs
@@ -7,6 +7,7 @@
     cachixClient,
     deployClientV1,
     deployClientV2,
+    withResponseTimeout,
   )
 where
 
@@ -14,6 +15,7 @@
 import Cachix.API.Deploy.V1 qualified as API.Deploy.V1
 import Cachix.API.Deploy.V2 qualified as API.Deploy.V2
 import Cachix.Types.ContentTypes ()
+import Network.HTTP.Client qualified as HTTP
 import Network.HTTP.Types (Status)
 import Protolude
 import Servant.API.Generic
@@ -21,6 +23,15 @@
 import Servant.Client qualified
 import Servant.Client.Streaming
 import Servant.Conduit ()
+
+-- | Override the timeout only for requests made by this client action.
+withResponseTimeout :: HTTP.ResponseTimeout -> ClientM a -> ClientM a
+withResponseTimeout responseTimeout = local $ \env ->
+  env
+    { makeClientRequest = \url request -> do
+        httpRequest <- makeClientRequest env url request
+        pure httpRequest {HTTP.responseTimeout = responseTimeout}
+    }
 
 isErr :: ClientError -> Status -> Bool
 isErr (Servant.Client.FailureResponse _ resp) status
diff --git a/src/Cachix/Deploy/Websocket.hs b/src/Cachix/Deploy/Websocket.hs
--- a/src/Cachix/Deploy/Websocket.hs
+++ b/src/Cachix/Deploy/Websocket.hs
@@ -3,6 +3,7 @@
 -- | A high-level, multiple reader, single writer interface for Websocket clients.
 module Cachix.Deploy.Websocket where
 
+import Cachix.API.WebSocket.Client qualified as WebSocketClient
 import Cachix.Client.Retry qualified as Retry
 import Cachix.Client.URI qualified as URI
 import Cachix.Client.Version (versionNumber)
@@ -178,13 +179,14 @@
 
 runClientWith :: Options -> WS.Connection.ConnectionOptions -> WS.ClientApp a -> IO a
 runClientWith Options {host, port, path, headers, useSSL} connectionOptions app =
-  if useSSL
-    then
-      withConnectionTimeout
-        timeoutMicroseconds
-        (Wuss.newSecureClientConnectionWith hostS (fromIntegral (URI.portNumber port)) (toS path) connectionOptions headers)
-        app
-    else WS.runClientWith hostS (URI.portNumber port) (toS path) connectionOptions headers app
+  WebSocketClient.withHandshakeErrors $
+    if useSSL
+      then
+        withConnectionTimeout
+          timeoutMicroseconds
+          (Wuss.newSecureClientConnectionWith hostS (fromIntegral (URI.portNumber port)) (toS path) connectionOptions headers)
+          app
+      else WS.runClientWith hostS (URI.portNumber port) (toS path) connectionOptions headers app
   where
     hostS = toS (URI.hostBS host)
     timeoutMicroseconds = WS.Connection.connectionTimeout connectionOptions * 1000 * 1000
diff --git a/test/ClientSpec.hs b/test/ClientSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/ClientSpec.hs
@@ -0,0 +1,56 @@
+module ClientSpec (spec) where
+
+import Protolude
+import StalledServer (withStalledServer)
+import System.Environment (getEnvironment)
+import System.IO.Temp (withSystemTempDirectory)
+import System.Posix.Signals (Signal, sigHUP, sigINT, sigKILL, sigTERM, signalProcess)
+import System.Process qualified as Process
+import System.Timeout (timeout)
+import Test.Hspec
+
+spec :: Spec
+spec = describe "signals during stalled requests" $
+  forM_ [("SIGINT", sigINT), ("SIGTERM", sigTERM), ("SIGHUP", sigHUP)] $ \(name, signal) ->
+    describe name $
+      forM_ ["http", "https"] $ \scheme -> do
+        it (toS $ "stops cachix use over " <> scheme) $
+          terminatesOnSignal signal scheme ["use", "somecache"]
+        it (toS $ "stops daemon startup over " <> scheme) $
+          terminatesOnSignal signal scheme ["daemon", "run", "somecache"]
+
+terminatesOnSignal :: Signal -> Text -> [Text] -> IO ()
+terminatesOnSignal signal scheme command =
+  withSystemTempDirectory "cachix-signal-test" $ \dir -> withFile (dir <> "/client.log") WriteMode $ \output ->
+    withStalledServer $ \port connected -> do
+      environment <- getEnvironment
+      let overrides =
+            [ ("CACHIX_AUTH_TOKEN", "test-token"),
+              ("no_proxy", "127.0.0.1"),
+              ("NO_PROXY", "127.0.0.1"),
+              ("CACHIX_DAEMON_SOCKET", dir <> "/daemon.sock"),
+              ("XDG_CONFIG_HOME", dir)
+            ]
+          args = ["--config", dir <> "/cachix.dhall", "--hostname", toS scheme <> "://127.0.0.1:" <> show port] <> map toS command
+          process =
+            (Process.proc "cachix" args)
+              { Process.env = Just (overrides <> filter (\(key, _) -> key `notElem` ("CACHIX_SIGNING_KEY" : map fst overrides)) environment),
+                Process.std_out = Process.UseHandle output,
+                Process.std_err = Process.UseHandle output
+              }
+      bracket (Process.createProcess process) cleanup $ \(_, _, _, processHandle) -> do
+        -- Wait for network I/O so the signal tests the initialized client,
+        -- rather than racing its startup and signal-handler installation.
+        timeout (10 * 1_000_000) connected `shouldReturn` Just ()
+        pid <- Process.getPid processHandle >>= maybe (panic "Missing client PID") pure
+        signalProcess signal pid
+        result <- timeout (5 * 1_000_000) (Process.waitForProcess processHandle)
+        result `shouldSatisfy` maybe False (/= ExitSuccess)
+  where
+    cleanup (_, _, _, processHandle) = do
+      Process.getProcessExitCode processHandle >>= \case
+        Just _ -> pure ()
+        Nothing -> do
+          pid <- Process.getPid processHandle
+          for_ pid $ signalProcess sigKILL
+          void $ Process.waitForProcess processHandle
diff --git a/test/EnvSpec.hs b/test/EnvSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/EnvSpec.hs
@@ -0,0 +1,36 @@
+module EnvSpec (spec) where
+
+import Cachix.Client.Env (customManagerSettings)
+import Network.HTTP.Client qualified as HTTP
+import Network.HTTP.Client.TLS (newTlsManagerWith)
+import Protolude
+import StalledServer (withStalledServer)
+import System.Timeout (timeout)
+import Test.Hspec
+
+spec :: Spec
+spec = parallel $ describe "HTTP manager timeouts" $ do
+  it "times out a TLS handshake when the server accepts but never answers" $
+    stalledRequest "https" isConnectionTimeout
+  it "times out an HTTP response when the server accepts but never answers" $
+    stalledRequest "http" isResponseTimeout
+
+stalledRequest :: Text -> (HTTP.HttpException -> Bool) -> IO ()
+stalledRequest scheme expected = withStalledServer $ \port _ -> do
+  manager <- newTlsManagerWith $ HTTP.managerSetProxy HTTP.noProxy customManagerSettings
+  request <- HTTP.parseRequest (toS scheme <> "://127.0.0.1:" <> show port)
+  -- Keep the real manager default so removing the production timeout fails
+  -- this test, while bounding the regression's own runtime.
+  result <- timeout (45 * 1_000_000) (try (HTTP.httpNoBody request manager))
+  case result of
+    Just (Left err) -> err `shouldSatisfy` expected
+    Just (Right _) -> expectationFailure "Expected the stalled request to time out"
+    Nothing -> expectationFailure "The HTTP manager did not time out the stalled request"
+
+isConnectionTimeout :: HTTP.HttpException -> Bool
+isConnectionTimeout (HTTP.HttpExceptionRequest _ HTTP.ConnectionTimeout) = True
+isConnectionTimeout _ = False
+
+isResponseTimeout :: HTTP.HttpException -> Bool
+isResponseTimeout (HTTP.HttpExceptionRequest _ HTTP.ResponseTimeout) = True
+isResponseTimeout _ = False
diff --git a/test/ServantSpec.hs b/test/ServantSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/ServantSpec.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE DataKinds #-}
+
+module ServantSpec (spec) where
+
+import Cachix.Client.Servant (withResponseTimeout)
+import Network.HTTP.Client qualified as HTTP
+import Network.Socket.ByteString qualified as Socket
+import Protolude
+import Servant.API (Get, JSON)
+import Servant.Client.Streaming
+import StalledServer (withServer)
+import Test.Hspec
+
+spec :: Spec
+spec = describe "scoped response timeout" $ do
+  it "allows a slow response only within the overridden action" $
+    withDelayedClient $ \env -> do
+      result <- runClientM (withResponseTimeout HTTP.responseTimeoutNone getNumber) env
+      result `shouldBe` Right 17
+      normalResult <- runClientM getNumber env
+      normalResult `shouldSatisfy` isResponseTimeout
+  it "keeps the default timeout for other actions" $
+    withDelayedClient $ \env -> do
+      result <- runClientM getNumber env
+      result `shouldSatisfy` isResponseTimeout
+
+getNumber :: ClientM Int
+getNumber = client (Proxy :: Proxy (Get '[JSON] Int))
+
+withDelayedClient :: (ClientEnv -> IO a) -> IO a
+withDelayedClient action = withServer respond $ \port _ -> do
+  manager <-
+    HTTP.newManager $
+      HTTP.managerSetProxy HTTP.noProxy $
+        HTTP.defaultManagerSettings
+          { HTTP.managerResponseTimeout = HTTP.responseTimeoutMicro 100_000
+          }
+  action $ mkClientEnv manager (BaseUrl Http "127.0.0.1" port "")
+  where
+    respond connection = do
+      void $ Socket.recv connection 4096
+      threadDelay 500_000
+      Socket.sendAll connection "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n17"
+
+isResponseTimeout :: Either ClientError a -> Bool
+isResponseTimeout (Left (ConnectionError exception)) =
+  case fromException exception of
+    Just (HTTP.HttpExceptionRequest _ HTTP.ResponseTimeout) -> True
+    _ -> False
+isResponseTimeout _ = False
diff --git a/test/StalledServer.hs b/test/StalledServer.hs
new file mode 100644
--- /dev/null
+++ b/test/StalledServer.hs
@@ -0,0 +1,26 @@
+module StalledServer (withStalledServer, withServer) where
+
+import Network.Socket qualified as Socket
+import Protolude
+
+-- Accept a connection and keep it open without sending any data. The action
+-- receives the port and an action that waits until the client has connected.
+withStalledServer :: (Int -> IO () -> IO a) -> IO a
+withStalledServer = withServer $ \_ -> forever $ threadDelay 1_000_000
+
+withServer :: (Socket.Socket -> IO ()) -> (Int -> IO () -> IO a) -> IO a
+withServer respond action =
+  bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \server -> do
+    Socket.bind server (Socket.SockAddrInet 0 (Socket.tupleToHostAddress (127, 0, 0, 1)))
+    Socket.listen server 1
+    address <- Socket.getSocketName server
+    port <- case address of
+      Socket.SockAddrInet port _ -> pure (fromIntegral port)
+      _ -> panic "Expected an IPv4 listener"
+    connected <- newEmptyMVar
+    withAsync
+      ( forever $ bracket (fst <$> Socket.accept server) Socket.close $ \connection -> do
+          void $ tryPutMVar connected ()
+          respond connection
+      )
+      (\_ -> action port (takeMVar connected))
