diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,8 @@
-## [_Unreleased_](https://github.com/freckle/stackctl/compare/v1.7.1.0...main)
+## [_Unreleased_](https://github.com/freckle/stackctl/compare/v1.7.2.0...main)
+
+## [v1.7.2.0](https://github.com/freckle/stackctl/compare/v1.7.1.0...v1.7.2.0)
+
+- Automatically cancel any ongoing update-stack operations on `^C`
 
 ## [v1.7.1.0](https://github.com/freckle/stackctl/compare/v1.7.0.0...v1.7.1.0)
 
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
 Tags. `stackctl` can be used to pretty-print, diff, and deploy these
 specifications.
 
-[spec]: https://github.com/freckle/stackctl/blob/main/doc/stackctl.1.md#stack-specifications
+[spec]: https://freckle.github.io/stackctl/#STACK-SPECIFICATIONS
 
 This project also contains a Haskell library for doing the same.
 
@@ -32,10 +32,11 @@
 curl -L https://raw.githubusercontent.com/freckle/stackctl/main/install | bash
 ```
 
-**NOTE**: some in the community have expressed [concerns][curlsh-bad] about the
-security of so-called "curl-sh" installations. We think the argument has been
-[pretty well debunked][curlsh-ok], but feel free to use the manual steps
-instead.
+> [!NOTE]
+> Some in the community have expressed [concerns][curlsh-bad] about the
+> security of so-called "curl-sh" installations. We think the argument has been
+> [pretty well debunked][curlsh-ok], but feel free to use the manual steps
+> instead.
 
 [curlsh-bad]: https://0x46.net/thoughts/2019/04/27/piping-curl-to-shell/
 [curlsh-ok]: https://www.arp242.net/curl-to-sh.html
diff --git a/src/Stackctl/AWS/CloudFormation.hs b/src/Stackctl/AWS/CloudFormation.hs
--- a/src/Stackctl/AWS/CloudFormation.hs
+++ b/src/Stackctl/AWS/CloudFormation.hs
@@ -41,6 +41,7 @@
   , awsCloudFormationGetStackNamesMatching
   , awsCloudFormationGetMostRecentStackEventId
   , awsCloudFormationDeleteStack
+  , awsCloudFormationCancelUpdateStack
   , awsCloudFormationWait
   , awsCloudFormationGetTemplate
 
@@ -50,6 +51,7 @@
   , changeSetJSON
   , ChangeSetId (..)
   , ChangeSetName (..)
+  , ChangeSetType (..)
   , Change (..)
   , ResourceChange (..)
   , Replacement (..)
@@ -66,6 +68,7 @@
 
 import Stackctl.Prelude
 
+import Amazonka.CloudFormation.CancelUpdateStack
 import Amazonka.CloudFormation.CreateChangeSet hiding (id)
 import Amazonka.CloudFormation.DeleteChangeSet
 import Amazonka.CloudFormation.DeleteStack
@@ -87,7 +90,7 @@
   , _ServiceError
   )
 import qualified Amazonka.Env as Amazonka
-import Amazonka.Waiter (Accept (..))
+import Amazonka.Waiter (Accept (..), Wait)
 import Conduit
 import Control.Lens ((?~))
 import Data.Aeson
@@ -211,7 +214,7 @@
   let req =
         newDescribeStackEvents
           & describeStackEvents_stackName
-          ?~ unStackName stackName
+            ?~ unStackName stackName
 
   runConduit
     $ AWS.paginate req
@@ -245,7 +248,7 @@
     req =
       newDescribeStackEvents
         & describeStackEvents_stackName
-        ?~ unStackName stackName
+          ?~ unStackName stackName
 
     -- Events are returned most-recent first, so "last" is "first" here
     getFirstEventId :: [StackEvent] -> Maybe Text
@@ -264,16 +267,21 @@
   => StackName
   -> m StackDeleteResult
 awsCloudFormationDeleteStack stackName = do
-  let
-    deleteReq = newDeleteStack $ unStackName stackName
-    describeReq =
-      newDescribeStacks & describeStacks_stackName ?~ unStackName stackName
-
-  AWS.simple deleteReq $ const $ pure ()
+  let req = newDeleteStack $ unStackName stackName
+  AWS.simple req $ const $ pure ()
 
   logDebug "Awaiting DeleteStack"
-  stackDeleteResult <$> AWS.await newStackDeleteComplete describeReq
+  stackDeleteResult <$> awaitStack newStackDeleteComplete stackName
 
+awsCloudFormationCancelUpdateStack
+  :: (MonadIO m, MonadLogger m, MonadAWS m) => StackName -> m ()
+awsCloudFormationCancelUpdateStack stackName = do
+  let req = newCancelUpdateStack $ unStackName stackName
+  AWS.simple req $ const $ pure ()
+
+  logDebug "Awaiting CancelUpdateStack"
+  void $ awaitStack newStackRollbackComplete stackName
+
 awsCloudFormationWait
   :: (MonadUnliftIO m, MonadAWS m)
   => StackName
@@ -281,10 +289,8 @@
 awsCloudFormationWait stackName = do
   either stackCreateResult stackUpdateResult
     <$> race
-      (AWS.await newStackCreateComplete req)
-      (AWS.await newStackUpdateComplete req)
- where
-  req = newDescribeStacks & describeStacks_stackName ?~ unStackName stackName
+      (awaitStack newStackCreateComplete stackName)
+      (awaitStack newStackUpdateComplete stackName)
 
 awsCloudFormationGetTemplate
   :: (MonadIO m, MonadAWS m) => StackName -> m Value
@@ -293,7 +299,7 @@
     req =
       newGetTemplate
         & (getTemplate_stackName ?~ unStackName stackName)
-        . (getTemplate_templateStage ?~ TemplateStage_Original)
+          . (getTemplate_templateStage ?~ TemplateStage_Original)
 
     -- If decodeStrict fails, assume it's a String of Yaml. See writeStackSpec.
     decodeTemplateBody body =
@@ -303,6 +309,13 @@
     body <- resp ^. getTemplateResponse_templateBody
     pure $ decodeTemplateBody body
 
+awaitStack
+  :: (MonadIO m, MonadAWS m) => Wait DescribeStacks -> StackName -> m Accept
+awaitStack waiter stackName =
+  AWS.await waiter
+    $ newDescribeStacks
+    & describeStacks_stackName ?~ unStackName stackName
+
 makeParameter :: Text -> Maybe Text -> Parameter
 makeParameter k v =
   newParameter & (parameter_parameterKey ?~ k) . (parameter_parameterValue .~ v)
@@ -330,6 +343,7 @@
   { csCreationTime :: UTCTime
   , csChanges :: Maybe [Change]
   , csChangeSetName :: ChangeSetName
+  , csChangeSetType :: ChangeSetType
   , csExecutionStatus :: ExecutionStatus
   , csChangeSetId :: ChangeSetId
   , csParameters :: Maybe [Parameter]
@@ -342,12 +356,14 @@
   , csResponse :: DescribeChangeSetResponse
   }
 
-changeSetFromResponse :: DescribeChangeSetResponse -> Maybe ChangeSet
-changeSetFromResponse resp =
+changeSetFromResponse
+  :: ChangeSetType -> DescribeChangeSetResponse -> Maybe ChangeSet
+changeSetFromResponse changeSetType resp =
   ChangeSet
     <$> (resp ^. describeChangeSetResponse_creationTime)
     <*> pure (fmap sortChanges $ resp ^. describeChangeSetResponse_changes)
     <*> (ChangeSetName <$> resp ^. describeChangeSetResponse_changeSetName)
+    <*> pure changeSetType
     <*> (resp ^. describeChangeSetResponse_executionStatus)
     <*> (ChangeSetId <$> resp ^. describeChangeSetResponse_changeSetId)
     <*> pure (resp ^. describeChangeSetResponse_parameters)
@@ -401,10 +417,10 @@
       let req =
             newCreateChangeSet (unStackName stackName) (unChangeSetName name)
               & (createChangeSet_changeSetType ?~ changeSetType)
-              . (createChangeSet_templateBody ?~ templateBody)
-              . (createChangeSet_parameters ?~ parameters)
-              . (createChangeSet_capabilities ?~ capabilities)
-              . (createChangeSet_tags ?~ tags)
+                . (createChangeSet_templateBody ?~ templateBody)
+                . (createChangeSet_parameters ?~ parameters)
+                . (createChangeSet_capabilities ?~ capabilities)
+                . (createChangeSet_tags ?~ tags)
 
       logInfo
         $ "Creating changeset..."
@@ -415,16 +431,17 @@
       void $ AWS.await newChangeSetCreateComplete $ newDescribeChangeSet csId
 
       logInfo "Retrieving changeset..."
-      cs <- awsCloudFormationDescribeChangeSet $ ChangeSetId csId
+      cs <- awsCloudFormationDescribeChangeSet changeSetType $ ChangeSetId csId
       pure $ cs <$ guard (not $ changeSetFailed cs)
 
 awsCloudFormationDescribeChangeSet
   :: (MonadIO m, MonadAWS m)
-  => ChangeSetId
+  => ChangeSetType
+  -> ChangeSetId
   -> m ChangeSet
-awsCloudFormationDescribeChangeSet changeSetId = do
+awsCloudFormationDescribeChangeSet changeSetType changeSetId = do
   let req = newDescribeChangeSet $ unChangeSetId changeSetId
-  AWS.simple req changeSetFromResponse
+  AWS.simple req $ changeSetFromResponse changeSetType
 
 sortChanges :: [Change] -> [Change]
 sortChanges = sortByDependencies changeName changeCausedBy
diff --git a/src/Stackctl/CancelHandler.hs b/src/Stackctl/CancelHandler.hs
new file mode 100644
--- /dev/null
+++ b/src/Stackctl/CancelHandler.hs
@@ -0,0 +1,33 @@
+module Stackctl.CancelHandler
+  ( with
+  , install
+  , remove
+  , trigger
+  ) where
+
+import Stackctl.Prelude
+
+import System.Posix.Signals
+
+-- | Install a 'keyboardSignal' handler, run an action, then remove it
+with :: MonadUnliftIO m => m a -> m b -> m b
+with f = bracket_ (install f) remove
+
+-- | Install a 'keyboardSignal' handler that runs the given action once
+install :: MonadUnliftIO m => m a -> m ()
+install f = do
+  withRunInIO $ \runInIO -> do
+    let handler = Catch $ void $ do
+          remove -- so next Ctl-C will truly cancel
+          runInIO f
+    void $ installHandler keyboardSignal handler Nothing
+
+-- | Remove the current handler for 'keyboardSignal' (i.e. install 'Default')
+remove :: MonadIO m => m ()
+remove = liftIO $ void $ installHandler keyboardSignal Default Nothing
+
+-- | Trigger the installed 'keyboardSignal' handler
+--
+-- This is used by our test suite.
+trigger :: MonadIO m => m ()
+trigger = liftIO $ raiseSignal keyboardSignal
diff --git a/src/Stackctl/Spec/Deploy.hs b/src/Stackctl/Spec/Deploy.hs
--- a/src/Stackctl/Spec/Deploy.hs
+++ b/src/Stackctl/Spec/Deploy.hs
@@ -14,6 +14,7 @@
 import Stackctl.AWS hiding (action)
 import Stackctl.AWS.Scope
 import Stackctl.Action
+import qualified Stackctl.CancelHandler as CancelHandler
 import Stackctl.Colors
 import Stackctl.Config (HasConfig)
 import Stackctl.DirectoryOption (HasDirectoryOption)
@@ -206,8 +207,16 @@
   mLastId <- awsCloudFormationGetMostRecentStackEventId stackName
   asyncTail <- async $ tailStackEventsSince stackName mLastId
 
+  let onCancel = do
+        logInfo "Canceling stack update, press ^C again to abort"
+        case csChangeSetType changeSet of
+          ChangeSetType_UPDATE -> do
+            awsCloudFormationCancelUpdateStack stackName
+            cancel asyncTail
+          t -> logWarn $ "Cannot cancel change-set of this type" :# ["type" .= t]
+
   logInfo $ "Executing ChangeSet" :# ["changeSetId" .= changeSetId]
-  result <- do
+  result <- CancelHandler.with onCancel $ do
     awsCloudFormationExecuteChangeSet changeSetId
     awsCloudFormationWait stackName
 
diff --git a/stackctl.cabal b/stackctl.cabal
--- a/stackctl.cabal
+++ b/stackctl.cabal
@@ -1,6 +1,6 @@
 cabal-version:   1.18
 name:            stackctl
-version:         1.7.1.0
+version:         1.7.2.0
 license:         MIT
 license-file:    LICENSE
 copyright:       2022 Renaissance Learning Inc
@@ -30,6 +30,7 @@
         Stackctl.AWS.Orphans
         Stackctl.AWS.Scope
         Stackctl.AWS.STS
+        Stackctl.CancelHandler
         Stackctl.CLI
         Stackctl.ColorOption
         Stackctl.Colors
@@ -120,6 +121,7 @@
         time >=1.11.1.1,
         transformers >=0.5.6.2,
         typed-process >=0.2.10.1,
+        unix >=2.7.2.2,
         unliftio >=0.2.25.0,
         unordered-containers >=0.2.19.1,
         uuid >=1.3.15,
@@ -161,6 +163,7 @@
         Stackctl.AWS.EC2Spec
         Stackctl.AWS.LambdaSpec
         Stackctl.AWS.ScopeSpec
+        Stackctl.CancelHandlerSpec
         Stackctl.Config.RequiredVersionSpec
         Stackctl.ConfigSpec
         Stackctl.FilterOptionSpec
diff --git a/test/Stackctl/CancelHandlerSpec.hs b/test/Stackctl/CancelHandlerSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Stackctl/CancelHandlerSpec.hs
@@ -0,0 +1,19 @@
+module Stackctl.CancelHandlerSpec
+  ( spec
+  ) where
+
+import Stackctl.Prelude
+
+import qualified Stackctl.CancelHandler as CancelHandler
+import Test.Hspec
+
+spec :: Spec
+spec = do
+  describe "with" $ do
+    fit "installs a handler for the duration of a block" $ example $ do
+      done <- newEmptyMVar
+
+      CancelHandler.install $ putMVar done ()
+      CancelHandler.trigger
+
+      takeMVar done `shouldReturn` ()
diff --git a/test/Stackctl/Spec/Changes/FormatSpec.hs b/test/Stackctl/Spec/Changes/FormatSpec.hs
--- a/test/Stackctl/Spec/Changes/FormatSpec.hs
+++ b/test/Stackctl/Spec/Changes/FormatSpec.hs
@@ -6,7 +6,7 @@
 import Stackctl.Prelude
 
 import Data.Aeson
-import Stackctl.AWS.CloudFormation (changeSetFromResponse)
+import Stackctl.AWS.CloudFormation (ChangeSetType (..), changeSetFromResponse)
 import Stackctl.Colors
 import Stackctl.Spec.Changes.Format
 import System.FilePath ((-<.>))
@@ -28,7 +28,7 @@
 formatChangeSetGolden path fmt = do
   actual <-
     formatChangeSet noColors OmitFull "some-stack" fmt
-      . (changeSetFromResponse <=< decodeStrict)
+      . (changeSetFromResponse ChangeSetType_UPDATE <=< decodeStrict)
       . encodeUtf8
       <$> readFileUtf8 path
 
