diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@
 
 ## Unreleased
 
+# [1.0.0] - 2022-09-06
+
+- Cachix Deploy: auto rollback if the agent can't connect to the backend service anymore
+
+- Cachix Deploy: allow specifying `rollbackScript`: https://docs.cachix.org/deploy/reference
+
+- Cachix Deploy: report `system` and closure size from the agent
+
+- Cachix Deploy: lock deployments so there's only one active at the time
+
+- Cachix Deploy: disable negative narinfo caching
+
 ## [0.8.1] - 2022-07-26
 
 - Cachix Deploy: retry exceptions every 1s instead of exponentially 
diff --git a/cachix-deployment/Main.hs b/cachix-deployment/Main.hs
--- a/cachix-deployment/Main.hs
+++ b/cachix-deployment/Main.hs
@@ -9,10 +9,12 @@
 import qualified Cachix.API.WebSocketSubprotocol as WSS
 import Cachix.Client.Retry
 import qualified Cachix.Deploy.Activate as Activate
+import qualified Cachix.Deploy.Lock as Lock
 import qualified Cachix.Deploy.Websocket as CachixWebsocket
+import qualified Cachix.Deploy.Websocket as Input
 import Conduit ((.|))
 import qualified Control.Concurrent.Async as Async
-import qualified Control.Concurrent.STM.TQueue as TQueue
+import qualified Control.Concurrent.STM.TMQueue as TMQueue
 import qualified Data.Aeson as Aeson
 import qualified Data.Conduit as Conduit
 import qualified Data.Conduit.Combinators as Conduit
@@ -30,13 +32,20 @@
 import System.IO (BufferMode (..), hSetBuffering)
 import qualified Wuss
 
+-- | Activate the new deployment.
+--
+-- If the target profile is already locked by another deployment, exit
+-- immediately and rely on the backend to reschedule.
 main :: IO ()
 main = do
   setLocaleEncoding utf8
   hSetBuffering stdout LineBuffering
   hSetBuffering stderr LineBuffering
+
   input <- escalateAs (FatalError . toS) . Aeson.eitherDecode . toS =<< getContents
-  CachixWebsocket.runForever (CachixWebsocket.websocketOptions input) (handleMessage input)
+  let profile = CachixWebsocket.profile . Input.websocketOptions $ input
+  void . Lock.withTryLock profile $
+    CachixWebsocket.runForever (CachixWebsocket.websocketOptions input) (handleMessage input)
 
 handleMessage :: CachixWebsocket.Input -> ByteString -> (K.KatipContextT IO () -> IO ()) -> WS.Connection -> CachixWebsocket.AgentState -> ByteString -> K.KatipContextT IO ()
 handleMessage input payload runKatip connection _ agentToken =
@@ -44,35 +53,39 @@
   where
     deploymentDetails = CachixWebsocket.deploymentDetails input
     options = CachixWebsocket.websocketOptions input
+
     handleCommand :: WSS.BackendCommand -> K.KatipContextT IO ()
     handleCommand (WSS.Deployment _) =
       K.logLocM K.ErrorS "cachix-deployment should have never gotten a deployment command directly."
     handleCommand (WSS.AgentRegistered agentInformation) = do
-      queue <- liftIO $ atomically TQueue.newTQueue
+      queue <- liftIO $ atomically TMQueue.newTMQueue
       let deploymentID = WSS.id (deploymentDetails :: WSS.DeploymentDetails)
           streamingThread = runLogStreaming (toS $ CachixWebsocket.host options) (CachixWebsocket.headers options agentToken) queue deploymentID
-          activateThread = runKatip $ do
-            Activate.activate options connection (Conduit.sinkTQueue queue) deploymentDetails agentInformation agentToken
-      liftIO $ Async.race_ streamingThread activateThread
-      throwIO ExitSuccess
-    runLogStreaming :: String -> RequestHeaders -> Conduit.TQueue ByteString -> UUID -> IO ()
+          activateThread =
+            runKatip
+              (Activate.activate options connection (Conduit.sinkTMQueue queue) deploymentDetails agentInformation agentToken)
+              `finally` atomically (TMQueue.closeTMQueue queue)
+      liftIO $ do
+        Async.concurrently_ streamingThread activateThread
+        -- TODO: move this into a `finally` after refactoring
+        WS.sendClose connection ("Closing." :: ByteString)
+        throwIO ExitSuccess
+
+    runLogStreaming :: String -> RequestHeaders -> TMQueue.TMQueue ByteString -> UUID -> IO ()
     runLogStreaming host headers queue deploymentID = do
       let path = "/api/v1/deploy/log/" <> UUID.toText deploymentID
       retryAllWithLogging endlessRetryPolicy (CachixWebsocket.logger runKatip) $ do
         liftIO $
           Wuss.runSecureClientWith host 443 (toS path) WS.defaultConnectionOptions headers $
             \conn ->
-              bracket_ (return ()) (WS.sendClose connection ("Closing." :: ByteString)) $
-                Conduit.runConduit $
-                  Conduit.sourceTQueue queue
-                    .| Conduit.linesUnboundedAscii
-                    -- TODO: prepend katip-like format to each line
-                    -- .| (if CachixWebsocket.isVerbose options then Conduit.print else mempty)
-                    .| sendLog conn
+              Conduit.runConduit $
+                Conduit.sourceTMQueue queue
+                  .| Conduit.linesUnboundedAscii
+                  -- TODO: prepend katip-like format to each line
+                  -- .| (if CachixWebsocket.isVerbose options then Conduit.print else mempty)
+                  .| sendLog conn
 
 sendLog :: WS.Connection -> Conduit.ConduitT ByteString Conduit.Void IO ()
-sendLog connection = Conduit.mapM_ f
-  where
-    f = \bs -> do
-      now <- getCurrentTime
-      WS.sendTextData connection $ Aeson.encode $ WSS.Log {WSS.line = toS bs, WSS.time = now}
+sendLog connection = Conduit.mapM_ $ \bs -> do
+  now <- getCurrentTime
+  WS.sendTextData connection $ Aeson.encode $ WSS.Log {WSS.line = toS bs, WSS.time = now}
diff --git a/cachix.cabal b/cachix.cabal
--- a/cachix.cabal
+++ b/cachix.cabal
@@ -1,12 +1,12 @@
 cabal-version:      2.2
 name:               cachix
-version:            0.8.1
+version:            1.0.0
 license:            Apache-2.0
 license-file:       LICENSE
-copyright:          2018 Domen Kožar
+copyright:          2018 Domen Kozar
 category:           Nix
 maintainer:         domen@cachix.org
-author:             Domen Kožar
+author:             Domen Kozar
 homepage:           https://github.com/cachix/cachix#readme
 bug-reports:        https://github.com/cachix/cachix/issues
 synopsis:
@@ -24,6 +24,7 @@
     NoImplicitPrelude
     DeriveAnyClass
     DeriveGeneric
+    DerivingVia
     OverloadedStrings
 
   ghc-options:
@@ -65,6 +66,7 @@
     Cachix.Deploy.Activate
     Cachix.Deploy.ActivateCommand
     Cachix.Deploy.Agent
+    Cachix.Deploy.Lock
     Cachix.Deploy.OptionsParser
     Cachix.Deploy.StdinProcess
     Cachix.Deploy.Websocket
@@ -90,6 +92,7 @@
     , dhall                   >=1.28.0
     , directory
     , ed25519
+    , extra
     , filepath
     , fsnotify
     , hercules-ci-cnix-store
@@ -100,6 +103,7 @@
     , http-types
     , inline-c-cpp
     , katip
+    , lukko
     , lzma-conduit
     , megaparsec              >=7.0.0
     , memory
@@ -169,6 +173,7 @@
     , protolude
     , safe-exceptions
     , stm
+    , stm-chans
     , stm-conduit
     , time
     , uuid
diff --git a/src/Cachix/Client/PushQueue.hs b/src/Cachix/Client/PushQueue.hs
--- a/src/Cachix/Client/PushQueue.hs
+++ b/src/Cachix/Client/PushQueue.hs
@@ -1,10 +1,10 @@
 {- Implements a queue with the following properties:
 
-- waits for queue to be fully pushed when exiting using ctrl-c (SIGINT)
+- waits for the queue to be fully pushed when exiting using ctrl-c (SIGINT)
 - allows stopping the producer
-- avoid duplicate pushing of the same store paths
+- avoids pushing duplicate store paths
 
-To safetly exit on demand, signal SIGINT.
+Use SIGINT to safely exit on demand.
 -}
 module Cachix.Client.PushQueue
   ( startWorkers,
@@ -16,6 +16,7 @@
 import qualified Cachix.Client.Push as Push
 import Cachix.Client.Retry (retryAll)
 import Control.Concurrent.Async
+import Control.Concurrent.Extra (once)
 import Control.Concurrent.STM (TVar, modifyTVar', newTVarIO, readTVar)
 import qualified Control.Concurrent.STM.Lock as Lock
 import qualified Control.Concurrent.STM.TBQueue as TBQueue
@@ -41,14 +42,14 @@
 worker :: Push.PushParams IO () -> PushWorkerState -> IO ()
 worker pushParams workerState = forever $ do
   storePath <- atomically $ TBQueue.readTBQueue $ pushQueue workerState
-  bracket_ (inProgresModify (+ 1)) (inProgresModify (\x -> x - 1)) $
+  bracket_ (inProgressModify (+ 1)) (inProgressModify (\x -> x - 1)) $
     retryAll $
-      \retrystatus ->
-        void $ do
-          maybeStorePath <- filterInvalidStorePath (Push.pushParamsStore pushParams) storePath
-          for maybeStorePath $ \validatedStorePath -> Push.uploadStorePath pushParams validatedStorePath retrystatus
+      \retrystatus -> do
+        maybeStorePath <- filterInvalidStorePath (Push.pushParamsStore pushParams) storePath
+        for_ maybeStorePath $ \validatedStorePath ->
+          Push.uploadStorePath pushParams validatedStorePath retrystatus
   where
-    inProgresModify f =
+    inProgressModify f =
       atomically $ modifyTVar' (inProgress workerState) f
 
 -- NOTE: producer is responsible for signaling SIGINT upon termination
@@ -66,7 +67,9 @@
   progress <- newTVarIO 0
   let pushWorkerState = PushWorkerState newPushQueue progress
   pushWorker <- async $ replicateConcurrently_ numWorkers $ worker pushParams pushWorkerState
-  let signalset = Signals.CatchOnce (exitOnceQueueIsEmpty stopProducerCallback pushWorker queryWorker queryWorkerState pushWorkerState)
+  let signalset =
+        Signals.CatchOnce $
+          exitOnceQueueIsEmpty stopProducerCallback pushWorker queryWorker queryWorkerState pushWorkerState
   void $ Signals.installHandler Signals.sigINT signalset Nothing
   void $ Signals.installHandler Signals.sigTERM signalset Nothing
   (_, eitherException) <- waitAnyCatchCancel [pushWorker, queryWorker]
@@ -99,12 +102,15 @@
     return (missingStorePathsSet, alreadyQueuedSet)
   queryLoop (workerState {alreadyQueued = S.union missingStorePathsSet alreadyQueuedSet}) pushqueue pushParams
 
+-- | Stop watching the store and push all pending store paths.
+-- This should only be used once per process.
 exitOnceQueueIsEmpty :: IO () -> Async () -> Async () -> QueryWorkerState -> PushWorkerState -> IO ()
-exitOnceQueueIsEmpty stopProducerCallback pushWorker queryWorker queryWorkerState pushWorkerState = do
-  putTextError "Stopped watching /nix/store and waiting for queue to empty ..."
-  Systemd.notifyStopping
-  stopProducerCallback
-  go
+exitOnceQueueIsEmpty stopProducerCallback pushWorker queryWorker queryWorkerState pushWorkerState =
+  join . once $ do
+    putTextError "Stopped watching /nix/store and waiting for queue to empty ..."
+    void Systemd.notifyStopping
+    stopProducerCallback
+    go
   where
     go = do
       (isDone, inprogress, queueLength) <- atomically $ do
@@ -121,7 +127,7 @@
           cancelWith pushWorker StopWorker
         else do
           -- extend shutdown for another 90s
-          Systemd.notify False $ "EXTEND_TIMEOUT_USEC=" <> show (90 * 1000 * 1000)
+          void $ Systemd.notify False $ "EXTEND_TIMEOUT_USEC=" <> show (90 * 1000 * 1000)
           putTextError $ "Waiting to finish: " <> show inprogress <> " pushing, " <> show queueLength <> " in queue"
           threadDelay (1000 * 1000)
           go
diff --git a/src/Cachix/Client/WatchStore.hs b/src/Cachix/Client/WatchStore.hs
--- a/src/Cachix/Client/WatchStore.hs
+++ b/src/Cachix/Client/WatchStore.hs
@@ -6,7 +6,6 @@
 import Cachix.Client.Push
 import qualified Cachix.Client.PushQueue as PushQueue
 import qualified Control.Concurrent.STM.TBQueue as TBQueue
-import Data.List (isSuffixOf)
 import Hercules.CNix.Store (Store)
 import qualified Hercules.CNix.Store as Store
 import Protolude
@@ -15,7 +14,7 @@
 
 startWorkers :: Store -> Int -> PushParams IO () -> IO ()
 startWorkers store numWorkers pushParams = do
-  Systemd.notifyReady
+  void Systemd.notifyReady
   withManager $ \mgr -> PushQueue.startWorkers numWorkers (producer store mgr) pushParams
 
 producer :: Store -> WatchManager -> PushQueue.Queue -> IO (IO ())
diff --git a/src/Cachix/Deploy/Activate.hs b/src/Cachix/Deploy/Activate.hs
--- a/src/Cachix/Deploy/Activate.hs
+++ b/src/Cachix/Deploy/Activate.hs
@@ -1,3 +1,4 @@
+{-# LANGUAGE CPP #-}
 {-# LANGUAGE DuplicateRecordFields #-}
 
 module Cachix.Deploy.Activate where
@@ -8,19 +9,28 @@
 import qualified Cachix.Deploy.Websocket as CachixWebsocket
 import qualified Cachix.Types.BinaryCache as BinaryCache
 import Cachix.Types.Permission (Permission (..))
+import qualified Data.Aeson as Aeson
+#if MIN_VERSION_aeson(2,0,0)
+import qualified Data.Aeson.KeyMap as HM
+#else
+import qualified Data.HashMap.Strict as HM
+#endif
 import qualified Data.Conduit as Conduit
 import qualified Data.Conduit.Combinators as Conduit
 import qualified Data.Conduit.Process as Conduit
 import Data.Time.Clock (getCurrentTime)
 import qualified Data.UUID.V4 as UUID
+import qualified Data.Vector as Vector
 import qualified Katip as K
 import qualified Network.WebSockets as WS
 import Protolude hiding (log, toS)
 import Protolude.Conv (toS)
 import Servant.Auth.Client (Token (..))
-import System.Directory (doesPathExist)
+import System.Directory (canonicalizePath, doesPathExist)
+import System.FilePath ((</>))
 import System.IO.Temp (withSystemTempDirectory)
 import System.Process
+import System.Timeout (timeout)
 import Prelude (String)
 
 domain :: CachixWebsocket.Options -> WSS.Cache -> Text
@@ -30,6 +40,9 @@
 uri :: CachixWebsocket.Options -> WSS.Cache -> Text
 uri options cache = "https://" <> domain options cache
 
+hackFlush :: K.KatipContextT IO ()
+hackFlush = liftIO $ threadDelay (5 * 1000 * 1000)
+
 -- TODO: what if websocket gets closed while deploying?
 activate ::
   CachixWebsocket.Options ->
@@ -39,70 +52,86 @@
   WSS.AgentInformation ->
   ByteString ->
   K.KatipContextT IO ()
-activate options connection sourceStream deploymentDetails agentInfo agentToken = do
+activate options connection sourceStream deploymentDetails agentInfo agentToken = withCacheArgs options agentInfo agentToken $ \cacheArgs -> do
   let storePath = WSS.storePath deploymentDetails
-      cachesArgs :: [String]
-      cachesArgs = case WSS.cache agentInfo of
-        Just cache ->
-          let officialCache = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
-              substituters = ["--option", "extra-substituters", toS (uri options cache)]
-              sigs = ["--option", "trusted-public-keys", officialCache <> " " <> toS (domain options cache) <> "-1:" <> toS (WSS.publicKey cache)]
-           in substituters ++ sigs
-        Nothing -> []
-      deploymentID = WSS.id (deploymentDetails :: WSS.DeploymentDetails)
-      deploymentFinished hasSucceeded now =
-        WSS.DeploymentFinished
-          { WSS.id = deploymentID,
-            WSS.time = now,
-            WSS.hasSucceeded = hasSucceeded
-          }
-      deploymentFailed = do
-        now <- liftIO getCurrentTime
-        K.logLocM K.InfoS $ K.ls $ "Deploying #" <> index <> " failed."
-        sendMessage $ deploymentFinished False now
-        -- hack to flush logs
-        liftIO $ threadDelay (5 * 1000 * 1000)
   K.logLocM K.InfoS $ K.ls $ "Deploying #" <> index <> ": " <> WSS.storePath deploymentDetails
-  -- notify the service deployment started
-  now <- liftIO getCurrentTime
-  sendMessage $
-    WSS.DeploymentStarted
-      { WSS.id = deploymentID,
-        WSS.time = now
-      }
-  -- TODO: don't create tmpfile for public caches
+  deploymentStarted Nothing
   -- TODO: add GC root so it's preserved for the next command
   -- get the store path using caches
-  (downloadExitCode, _, _) <- liftIO $
-    withSystemTempDirectory "netrc" $
-      \dir -> do
-        let filepath = dir <> "netrc"
-        args <- case WSS.cache agentInfo of
-          Just cache -> do
-            -- TODO: ugh
-            let bc =
-                  BinaryCache.BinaryCache
-                    { BinaryCache.name = "",
-                      BinaryCache.uri = toS (uri options cache),
-                      BinaryCache.publicSigningKeys = [],
-                      BinaryCache.isPublic = WSS.isPublic cache,
-                      BinaryCache.githubUsername = "",
-                      BinaryCache.permission = Read
-                    }
-            NetRc.add (Token agentToken) [bc] filepath
-            return $ cachesArgs <> ["--option", "netrc-file", filepath]
-          Nothing ->
-            return cachesArgs
-        shellOut "nix-store" (["-r", toS storePath] <> args)
+  (downloadExitCode, _, _) <-
+    liftIO $ shellOut "nix-store" (["-r", toS storePath] <> cacheArgs)
   -- TODO: use exceptions to simplify this code
   case downloadExitCode of
     ExitFailure _ -> deploymentFailed
     ExitSuccess -> do
-      (profile, activationScripts) <- liftIO $ getActivationScript storePath (CachixWebsocket.profile options)
-      -- TODO: document what happens if wrong user is used for the agent
+      -- TODO: query the remote store to get the size before downloading (and possibly running out of disk space)
+      (_, pathInfoJSON, _) <- liftIO $ shellNoStream "nix" (cacheArgs <> ["--extra-experimental-features", "nix-command", "path-info", "-S", "--json", toS storePath])
+      deploymentStarted $ extractClosureSize =<< Aeson.decode (toS pathInfoJSON)
 
-      -- set the new profile
-      (activateProfileExitCode, _, _) <- liftIO $ shellOut "nix-env" ["-p", toS profile, "--set", toS storePath]
+      activateProfile storePath $ \oldStorePath -> do
+        -- connect the the backend to see if we broke networking
+        websocketSucceeded <- liftIO $ timeout (10 * 1000 * 1000) $ CachixWebsocket.runOnce options $ \_ _ _ _ -> return ()
+        case websocketSucceeded of
+          Nothing -> rollback oldStorePath
+          Just () -> do
+            case WSS.rollbackScript deploymentDetails of
+              Just script -> do
+                (downloadRollbackExitCode, _, _) <- liftIO $ shellOut "nix-store" (["-r", toS script] <> cacheArgs)
+                case downloadRollbackExitCode of
+                  ExitFailure _ -> deploymentFailed
+                  ExitSuccess -> do
+                    (rollbackExitCode, _, _) <- liftIO $ shellOut (toS script) []
+                    case rollbackExitCode of
+                      ExitFailure _ -> rollback oldStorePath
+                      ExitSuccess -> deloymentSucceeded
+              Nothing -> deloymentSucceeded
+  where
+    deploymentID = WSS.id (deploymentDetails :: WSS.DeploymentDetails)
+    deloymentSucceeded = do
+      now <- liftIO getCurrentTime
+      sendMessage $ deploymentFinished True now
+      liftIO $ log "Successfully activated the deployment."
+      K.logLocM K.InfoS $ K.ls $ "Deployment #" <> index <> " finished"
+      hackFlush
+    deploymentStarted closureSize = do
+      now <- liftIO getCurrentTime
+      sendMessage $
+        WSS.DeploymentStarted
+          { WSS.id = deploymentID,
+            WSS.time = now,
+            WSS.closureSize = closureSize
+          }
+    deploymentFinished hasSucceeded now =
+      WSS.DeploymentFinished
+        { WSS.id = deploymentID,
+          WSS.time = now,
+          WSS.hasSucceeded = hasSucceeded
+        }
+    deploymentFailed = do
+      now <- liftIO getCurrentTime
+      liftIO $ log "Failed to activate the deployment."
+      K.logLocM K.InfoS $ K.ls $ "Deploying #" <> index <> " failed."
+      sendMessage $ deploymentFinished False now
+      hackFlush
+    rollback :: Maybe Text -> K.KatipContextT IO ()
+    rollback (Just path) = do
+      liftIO $ log "Deployment failed, rolling back ..."
+      activateProfile path $ const deploymentFailed
+    rollback Nothing = do
+      liftIO $ log "Skipping rollback as this is the first deployment."
+      deploymentFailed
+    activateProfile :: Text -> (Maybe Text -> K.KatipContextT IO ()) -> K.KatipContextT IO ()
+    activateProfile path action = do
+      (profilePath, activationScripts) <- liftIO $ getActivationScript path (CachixWebsocket.profile options)
+      profileExists <- liftIO $ doesPathExist profilePath
+      -- in case we're doing the first deployment the profile won't exist so we can't rollback
+      previousStorePath <-
+        if profileExists
+          then do
+            oldStorePath <- liftIO $ canonicalizePath profilePath
+            return $ Just $ toS oldStorePath
+          else return Nothing
+      (activateProfileExitCode, _, _) <- liftIO $ shellOut "nix-env" ["-p", toS profilePath, "--set", toS path]
       case activateProfileExitCode of
         ExitFailure _ -> deploymentFailed
         ExitSuccess -> do
@@ -112,19 +141,17 @@
             return activateScriptExitCode
           if not (all (== ExitSuccess) exitCodes)
             then deploymentFailed
-            else do
-              now <- liftIO getCurrentTime
-              sendMessage $ deploymentFinished True now
-              liftIO $ log "Successfully activated the deployment."
-              -- TODO: this is a hack to make sure the deployment is finished
-              liftIO $ threadDelay (5 * 1000 * 1000)
-              K.logLocM K.InfoS $ K.ls $ "Deployment #" <> index <> " finished"
-  where
-    -- TODO: prevent service from being restarted while deploying
-    -- TODO: upgrade agent
-
+            else action previousStorePath
     index :: Text
     index = show $ WSS.index deploymentDetails
+    -- TODO: it would be better to stream and also return the text
+    shellNoStream :: FilePath -> [String] -> IO (ExitCode, String, String)
+    shellNoStream cmd args = do
+      log $ "$ " <> toS cmd <> " " <> toS (unwords $ fmap toS args)
+      (exitCode, procstdout, procstderr) <- readProcessWithExitCode cmd args ""
+      log $ toS procstderr
+      return (exitCode, toS procstdout, toS procstderr)
+    shellOut :: FilePath -> [String] -> IO (ExitCode, (), ())
     shellOut cmd args = do
       log $ "$ " <> toS cmd <> " " <> toS (unwords $ fmap toS args)
       Conduit.sourceProcessWithStreams (proc cmd args) Conduit.sinkNull sourceStream sourceStream
@@ -146,25 +173,65 @@
         method = case command of
           WSS.DeploymentStarted {} -> "DeploymentStarted"
           WSS.DeploymentFinished {} -> "DeploymentFinished"
-    getActivationScript :: Text -> Text -> IO (Text, [Command])
+    -- TODO: home-manager
+    getActivationScript :: Text -> Text -> IO (FilePath, [Command])
     getActivationScript storePath profile = do
-      isNixOS <- doesPathExist $ toS $ storePath <> "/nixos-version"
-      isNixDarwin <- doesPathExist $ toS $ storePath <> "/darwin-version"
+      isNixOS <- doesPathExist $ toS storePath </> "nixos-version"
+      isNixDarwin <- doesPathExist $ toS storePath </> "darwin-version"
       user <- InstallationMode.getUser
       (systemProfile, cmds) <- case (isNixOS, isNixDarwin) of
-        (True, _) -> return ("system", [(toS storePath <> "/bin/switch-to-configuration", ["switch"])])
+        (True, _) -> return ("system", [(toS storePath </> "bin/switch-to-configuration", ["switch"])])
         (_, True) ->
           -- https://github.com/LnL7/nix-darwin/blob/master/pkgs/nix-tools/darwin-rebuild.sh
           return
             ( "system-profiles/system",
               [ ("mkdir", ["-p", "-m", "0755", "/nix/var/nix/profiles/system-profiles"]),
-                (toS storePath <> "/activate-user", []),
-                (toS storePath <> "/activate", [])
+                (toS storePath </> "activate-user", []),
+                (toS storePath </> "activate", [])
               ]
             )
         (_, _) -> return ("system", [])
-      return ("/nix/var/nix/profiles/" <> if profile == "" then systemProfile else profile, cmds)
+      return ("/nix/var/nix/profiles" </> if profile == "" then systemProfile else toS profile, cmds)
 
 type Command = (String, [String])
 
--- TODO: home-manager
+extractClosureSize :: Aeson.Value -> Maybe Int64
+extractClosureSize (Aeson.Array vector) = case Vector.toList vector of
+  [Aeson.Object obj] -> case HM.lookup "closureSize" obj of
+    Just (Aeson.Number num) -> Just $ floor num
+    _ -> Nothing
+  _ -> Nothing
+extractClosureSize _ = Nothing
+
+-- TODO: don't create tmpfile for public caches
+withCacheArgs :: CachixWebsocket.Options -> WSS.AgentInformation -> ByteString -> ([String] -> K.KatipContextT IO ()) -> K.KatipContextT IO ()
+withCacheArgs options agentInfo agentToken m =
+  withSystemTempDirectory "netrc" $ \dir -> do
+    let filepath = dir </> "netrc"
+    args <- case WSS.cache agentInfo of
+      Just cache -> do
+        -- TODO: ugh
+        let bc =
+              BinaryCache.BinaryCache
+                { BinaryCache.name = "",
+                  BinaryCache.uri = toS (uri options cache),
+                  BinaryCache.publicSigningKeys = [],
+                  BinaryCache.isPublic = WSS.isPublic cache,
+                  BinaryCache.githubUsername = "",
+                  BinaryCache.permission = Read
+                }
+        liftIO $ NetRc.add (Token agentToken) [bc] filepath
+        return $ cachesArgs <> ["--option", "netrc-file", filepath]
+      Nothing ->
+        return cachesArgs
+    m args
+  where
+    cachesArgs :: [String]
+    cachesArgs = case WSS.cache agentInfo of
+      Just cache ->
+        let officialCache = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
+            substituters = ["--option", "extra-substituters", toS (uri options cache)]
+            noNegativeCaching = ["--option", "narinfo-cache-negative-ttl", "0"]
+            sigs = ["--option", "trusted-public-keys", officialCache <> " " <> toS (domain options cache) <> "-1:" <> toS (WSS.publicKey cache)]
+         in substituters ++ sigs ++ noNegativeCaching
+      Nothing -> []
diff --git a/src/Cachix/Deploy/Agent.hs b/src/Cachix/Deploy/Agent.hs
--- a/src/Cachix/Deploy/Agent.hs
+++ b/src/Cachix/Deploy/Agent.hs
@@ -17,29 +17,32 @@
 import qualified Servant.Client as Servant
 
 run :: CachixOptions.CachixOptions -> AgentOptions.AgentOptions -> IO ()
-run cachixOptions agentOpts = do
+run cachixOptions agentOpts =
   CachixWebsocket.runForever options handleMessage
   where
     host = toS $ Servant.baseUrlHost $ getBaseUrl $ CachixOptions.host cachixOptions
     name = AgentOptions.name agentOpts
+    profile = fromMaybe "system" (AgentOptions.profile agentOpts)
     options =
       CachixWebsocket.Options
         { CachixWebsocket.host = host,
           CachixWebsocket.name = name,
           CachixWebsocket.path = "/ws",
-          CachixWebsocket.profile = AgentOptions.profile agentOpts,
+          CachixWebsocket.profile = profile,
           CachixWebsocket.isVerbose = CachixOptions.verbose cachixOptions
         }
     handleMessage :: ByteString -> (K.KatipContextT IO () -> IO ()) -> WS.Connection -> CachixWebsocket.AgentState -> ByteString -> K.KatipContextT IO ()
-    handleMessage payload _ _ agentState _ = do
+    handleMessage payload _ _ agentState _ =
       CachixWebsocket.parseMessage payload (handleCommand . WSS.command)
       where
         handleCommand :: WSS.BackendCommand -> K.KatipContextT IO ()
-        handleCommand (WSS.AgentRegistered agentInformation) = do
+        handleCommand (WSS.AgentRegistered agentInformation) =
           CachixWebsocket.registerAgent agentState agentInformation
-        handleCommand (WSS.Deployment deploymentDetails) = do
-          -- TODO: lock to ensure one deployment at the time
-          let input =
+        handleCommand (WSS.Deployment deploymentDetails) =
+          liftIO $ do
+            binDir <- toS <$> getBinDir
+            readProcess (binDir <> "/.cachix-deployment") [] $
+              toS . Aeson.encode $
                 CachixWebsocket.Input
                   { deploymentDetails = deploymentDetails,
                     websocketOptions =
@@ -47,9 +50,7 @@
                         { host = host,
                           name = name,
                           path = "/ws-deployment",
-                          profile = AgentOptions.profile agentOpts,
+                          profile = profile,
                           isVerbose = CachixOptions.verbose cachixOptions
                         }
                   }
-          binDir <- toS <$> liftIO getBinDir
-          liftIO $ readProcess (binDir <> "/.cachix-deployment") [] (toS $ Aeson.encode input)
diff --git a/src/Cachix/Deploy/Lock.hs b/src/Cachix/Deploy/Lock.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Deploy/Lock.hs
@@ -0,0 +1,37 @@
+module Cachix.Deploy.Lock (withTryLock) where
+
+import qualified Lukko as Lock
+import Protolude hiding ((<.>))
+import qualified System.Directory as Directory
+import System.FilePath ((<.>), (</>))
+
+defaultLockDirectory :: FilePath
+defaultLockDirectory = "cachix" </> "deploy" </> "locks"
+
+-- | Run an IO action with an acquired profile lock. Returns immediately if the profile is already locked.
+--
+-- Lock files are not deleted after use.
+--
+-- macOS: if using sudo, make sure to use `-H` to reset the home directory.
+withTryLock :: Text -> IO a -> IO (Maybe a)
+withTryLock profileName action = do
+  lockDirectory <- Directory.getXdgDirectory Directory.XdgCache defaultLockDirectory
+
+  Directory.createDirectoryIfMissing True lockDirectory
+  Directory.setPermissions lockDirectory $
+    Directory.emptyPermissions
+      & Directory.setOwnerReadable True
+      & Directory.setOwnerWritable True
+      & Directory.setOwnerExecutable True
+      & Directory.setOwnerSearchable True
+
+  let lockFile = lockDirectory </> toS profileName <.> "lock"
+
+  bracket
+    (Lock.fdOpen lockFile)
+    (Lock.fdUnlock *> Lock.fdClose)
+    $ \fd -> do
+      isLocked <- Lock.fdTryLock fd Lock.ExclusiveLock
+      if isLocked
+        then Just <$> action
+        else pure Nothing
diff --git a/src/Cachix/Deploy/OptionsParser.hs b/src/Cachix/Deploy/OptionsParser.hs
--- a/src/Cachix/Deploy/OptionsParser.hs
+++ b/src/Cachix/Deploy/OptionsParser.hs
@@ -27,7 +27,7 @@
 
 data AgentOptions = AgentOptions
   { name :: Text,
-    profile :: Text
+    profile :: Maybe Text
   }
   deriving (Show)
 
@@ -43,10 +43,11 @@
       ( metavar "AGENT-NAME"
           <> help "Unique identifier (usually hostname)."
       )
-    <*> strArgument
-      ( value ""
-          <> metavar "NIX-PROFILE"
-          <> help "Nix profile to manage. Defaults to 'system' on NixOS and 'system-profiles/system' on nix-darwin."
+    <*> optional
+      ( strArgument
+          ( metavar "NIX-PROFILE"
+              <> help "Nix profile to manage. Defaults to 'system' on NixOS and 'system-profiles/system' on nix-darwin."
+          )
       )
 
 parserActivateOptions :: Parser ActivateOptions
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
@@ -6,6 +6,7 @@
 import Cachix.Client.Retry
 import Cachix.Client.Version (versionNumber)
 import qualified Cachix.Deploy.WebsocketPong as WebsocketPong
+import Control.Exception.Safe (catchAny, onException)
 import Control.Retry (RetryStatus (..))
 import Data.Aeson (FromJSON, ToJSON)
 import Data.IORef
@@ -13,9 +14,13 @@
 import qualified Katip as K
 import Network.HTTP.Types (Header)
 import qualified Network.WebSockets as WS
-import Protolude hiding (toS)
+import Protolude hiding (catch, onException, toS)
 import Protolude.Conv
-import System.Environment (getEnv)
+import qualified System.Directory as Directory
+import System.Environment (getEnv, lookupEnv)
+import qualified System.Info
+import qualified System.Posix.Files as Posix.Files
+import qualified System.Posix.User as Posix.User
 import qualified Wuss
 
 type AgentState = IORef (Maybe WSS.AgentInformation)
@@ -35,15 +40,35 @@
   }
   deriving (Show, Generic, ToJSON, FromJSON)
 
+system :: String
+system = System.Info.arch <> "-" <> System.Info.os
+
 runForever :: Options -> (ByteString -> (K.KatipContextT IO () -> IO ()) -> WS.Connection -> AgentState -> ByteString -> K.KatipContextT IO ()) -> IO ()
-runForever options cmd = withKatip (isVerbose options) $ \logEnv -> do
-  agentToken <- getEnv "CACHIX_AGENT_TOKEN"
+runForever options cmd =
+  runOnce options f
+  where
+    f runKatip connection agentState agentToken =
+      liftIO $
+        WSS.recieveDataConcurrently
+          connection
+          (\message -> runKatip (cmd message runKatip connection agentState agentToken))
+
+runOnce :: Options -> ((K.KatipContextT IO () -> IO ()) -> WS.Connection -> AgentState -> ByteString -> K.KatipContextT IO ()) -> IO ()
+runOnce options cmd = withKatip (isVerbose options) $ \logEnv -> do
+  let runKatip = K.runKatipContextT logEnv () "agent"
+
+  checkUserOwnsHome `catchAny` \e -> do
+    runKatip $ K.logLocM K.ErrorS $ K.ls (displayException e)
+    exitFailure
+
   -- TODO: error if token is missing
+  agentToken <- getEnv "CACHIX_AGENT_TOKEN"
   agentState <- newIORef Nothing
+
   pongState <- WebsocketPong.newState
   mainThreadID <- myThreadId
-  let runKatip = K.runKatipContextT logEnv () "agent"
-      pingHandler = do
+
+  let pingHandler = do
         last <- WebsocketPong.secondsSinceLastPong pongState
         runKatip $ K.logLocM K.DebugS $ K.ls $ "Sending WebSocket keep-alive ping, last pong was " <> (show last :: Text) <> " seconds ago"
         WebsocketPong.pingHandler pongState mainThreadID pongTimeout
@@ -61,10 +86,7 @@
             K.logLocM K.InfoS "Connected to Cachix Deploy service"
             liftIO $
               WS.withPingThread connection pingEvery pingHandler $
-                do
-                  WSS.recieveDataConcurrently
-                    connection
-                    (\message -> runKatip (cmd message runKatip connection agentState (toS agentToken)))
+                runKatip $ cmd runKatip connection agentState (toS agentToken)
   where
     agentIdentifier = name options <> " " <> toS versionNumber
     pingEvery = 30
@@ -74,7 +96,8 @@
 headers options agentToken =
   [ ("Authorization", "Bearer " <> toS agentToken),
     ("name", toS (name options)),
-    ("version", toS versionNumber)
+    ("version", toS versionNumber),
+    ("system", toS system)
   ]
 
 -- TODO: log the exception
@@ -111,3 +134,43 @@
 registerAgent agentState agentInformation = do
   K.logLocM K.InfoS "Agent registered."
   liftIO $ atomicWriteIORef agentState (Just agentInformation)
+
+-- | Fetch the home directory and verify that the owner matches the current user.
+-- Throws either 'NoHomeFound' or 'UserDoesNotOwnHome'.
+checkUserOwnsHome :: IO ()
+checkUserOwnsHome = do
+  home <- Directory.getHomeDirectory `onException` throwIO NoHomeFound
+  stat <- Posix.Files.getFileStatus home
+  userId <- Posix.User.getEffectiveUserID
+
+  when (userId /= Posix.Files.fileOwner stat) $ do
+    userName <- Posix.User.userName <$> Posix.User.getUserEntryForID userId
+    sudoUser <- lookupEnv "SUDO_USER"
+    throwIO $
+      UserDoesNotOwnHome
+        { userName = userName,
+          sudoUser = sudoUser,
+          home = home
+        }
+
+data Error
+  = -- | No home directory.
+    NoHomeFound
+  | -- | Safeguard against creating root-owned files in user directories.
+    -- This is an issue on macOS, where, by default, sudo does not reset $HOME.
+    UserDoesNotOwnHome
+      { userName :: String,
+        sudoUser :: Maybe String,
+        home :: FilePath
+      }
+  deriving (Show)
+
+instance Exception Error where
+  displayException NoHomeFound = "Could not find the user’s home directory. Make sure to set the $HOME variable."
+  displayException UserDoesNotOwnHome {userName = userName, sudoUser = sudoUser, home = home} =
+    if isJust sudoUser
+      then toS $ unlines [warningMessage, suggestSudoFlagH]
+      else toS warningMessage
+    where
+      warningMessage = "The current user (" <> toS userName <> ") does not own the home directory (" <> toS home <> ")"
+      suggestSudoFlagH = "Try running the agent with `sudo -H`."
