diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,3 +1,16 @@
+## 0.3.0
+
+* Add BDCS.Groups.getGroupsLike, which returns groups whose names match the
+  % and _ SQL wildcards.
+* Add BDCS.Groups.getGroupsTotal, which returns the number of groups in the
+  database.
+* Add BDCS.DB.firstListResult, which returns the first value from the
+  non-empty result list of an SQL query.
+* The types of many export-related functions have been modified to include a
+  constraint on MonadLoggerIO.
+* Debug logging added to exportAndCustomize, runCustomizations, ostreeSink,
+  qcow2Sink, and runHacks.
+
 ## 0.2.4
 
 * Allow building with aeson-1.3 and unordered-containers-0.2.9.
diff --git a/bdcs.cabal b/bdcs.cabal
--- a/bdcs.cabal
+++ b/bdcs.cabal
@@ -1,5 +1,5 @@
 name:                   bdcs
-version:                0.2.4
+version:                0.3.0
 synopsis:               Tools for managing a content store of software packages
 description:            This module provides a library and various tools for managing a content store and
                         metadata database.  These store the contents of software packages that make up a
@@ -110,7 +110,8 @@
                         BDCS.Utils.Error,
                         BDCS.Utils.Filesystem,
                         BDCS.Utils.Mode,
-                        BDCS.Utils.Monad
+                        BDCS.Utils.Monad,
+                        BDCS.Utils.Process,
                         BDCS.Version
 
     build-depends:      aeson >= 1.0.0.0 && < 1.4.0.0,
@@ -334,7 +335,8 @@
 
     build-depends:      bdcs,
                         base >= 4.9 && < 5.0,
-                        directory >= 1.3.0.0 && < 1.4.0.0
+                        directory >= 1.3.0.0 && < 1.4.0.0,
+                        monad-logger >= 0.3.20.2 && < 0.3.28.2
 
     default-language:   Haskell2010
 
diff --git a/src/BDCS/DB.hs b/src/BDCS/DB.hs
--- a/src/BDCS/DB.hs
+++ b/src/BDCS/DB.hs
@@ -229,6 +229,12 @@
 firstKeyResult query =
     listToMaybe . map unValue <$> query
 
+-- | Run an SQL query, returning the first value from the result list.
+-- Use this when you want a single index out of the database and it is guaranteed not to be empty.
+firstListResult :: Monad m => m [Value a] -> m a
+firstListResult query =
+    head . map unValue <$> query
+
 -- | Like 'maybe', but for keys.  If the key is nothing, return the default value.  Otherwise,
 -- run the function on the key and return that value.
 maybeKey :: MonadIO m =>
diff --git a/src/BDCS/Export.hs b/src/BDCS/Export.hs
--- a/src/BDCS/Export.hs
+++ b/src/BDCS/Export.hs
@@ -20,7 +20,7 @@
 
 import           Control.Conditional(cond)
 import           Control.Monad.Except(MonadError, runExceptT, throwError)
-import           Control.Monad.IO.Class(MonadIO, liftIO)
+import           Control.Monad.Logger(MonadLoggerIO, logDebugN)
 import           Control.Monad.Trans.Resource(MonadBaseControl, MonadResource)
 import           Data.Conduit(Consumer, (.|), runConduit, runConduitRes)
 import qualified Data.Conduit.List as CL
@@ -42,10 +42,10 @@
 import           BDCS.Files(groupIdToFilesC)
 import           BDCS.Groups(getGroupIdC)
 
-export :: (MonadBaseControl IO m, MonadError String m, MonadResource m) => FilePath -> FilePath -> [T.Text] -> SqlPersistT m ()
+export :: (MonadBaseControl IO m, MonadError String m, MonadLoggerIO m, MonadResource m) => FilePath -> FilePath -> [T.Text] -> SqlPersistT m ()
 export repo out_path things = exportAndCustomize repo out_path things []
 
-exportAndCustomize :: (MonadBaseControl IO m, MonadError String m, MonadResource m) => FilePath -> FilePath -> [T.Text] -> [Customization] -> SqlPersistT m ()
+exportAndCustomize :: (MonadBaseControl IO m, MonadError String m, MonadLoggerIO m, MonadResource m) => FilePath -> FilePath -> [T.Text] -> [Customization] -> SqlPersistT m ()
 exportAndCustomize repo out_path things custom | kernelMissing out_path things = throwError "ERROR: ostree exports need a kernel package included"
                                                | otherwise                     = do
     let objectSink = cond [(".tar" `isSuffixOf` out_path,   CS.objectToTarEntry .| Tar.tarSink out_path),
@@ -66,13 +66,15 @@
 
             runConduitRes $ fstreeSource fstree' .| filesToObjectsC overlay' cs .| objectSink
  where
-    directoryOutput :: (MonadError String m, MonadIO m) => FilePath -> Consumer (Files, CS.Object) m ()
+    directoryOutput :: (MonadError String m, MonadLoggerIO m) => FilePath -> Consumer (Files, CS.Object) m ()
     directoryOutput path = do
         -- Apply tmpfiles.d to the directory first
-        liftIO $ runTmpfiles path
+        logDebugN "Running tmpfiles"
+        runTmpfiles path
 
         Directory.directorySink path
-        liftIO $ runHacks path
+        logDebugN "Running standard hacks"
+        runHacks path
 
     kernelMissing :: FilePath -> [T.Text] -> Bool
     kernelMissing out lst = ".repo" `isSuffixOf` out && not (any ("kernel-" `T.isPrefixOf`) lst)
diff --git a/src/BDCS/Export/Customize.hs b/src/BDCS/Export/Customize.hs
--- a/src/BDCS/Export/Customize.hs
+++ b/src/BDCS/Export/Customize.hs
@@ -11,6 +11,7 @@
 import           Control.Monad(foldM)
 import           Control.Monad.Except(MonadError)
 import           Control.Monad.IO.Class(MonadIO)
+import           Control.Monad.Logger(MonadLogger, MonadLoggerIO, logDebugN)
 import           Crypto.Hash(Digest, hash)
 import           Crypto.Hash.Algorithms(Blake2b_256)
 import           Data.ByteArray(convert)
@@ -18,6 +19,7 @@
 import           Data.Conduit(Conduit, awaitForever, yield)
 import           Data.ContentStore(ContentStore)
 import qualified Data.Map.Strict as Map
+import qualified Data.Text as T
 
 import BDCS.CS(Object(..), fileToObjectC)
 import BDCS.DB(Files(..))
@@ -38,8 +40,10 @@
         Nothing  -> fileToObjectC repo f
         Just obj -> yield (f, obj)
 
-addToOverlay :: MonadError String m => CSOverlay -> FSTree -> Files -> Maybe BS.ByteString -> m (CSOverlay, FSTree)
+addToOverlay :: (MonadError String m, MonadLogger m) => CSOverlay -> FSTree -> Files -> Maybe BS.ByteString -> m (CSOverlay, FSTree)
 addToOverlay overlay tree file content = do
+    logDebugN $ T.pack "Adding to overlay: " `T.append` filesPath file
+
     -- If the file has content, create a hash of it and add the content to the overlay.
     -- The digest type doesn't need to match the content store, it just needs to be something we can
     -- use as a hash key.
@@ -58,8 +62,10 @@
         let digest = hash input :: Digest Blake2b_256
         in  convert digest
 
-runCustomizations :: (MonadError String m, MonadIO m) => CSOverlay -> ContentStore -> FSTree -> [Customization] -> m (CSOverlay, FSTree)
-runCustomizations overlay _repo tree customizations = foldM runCustomization (overlay, tree) customizations
+runCustomizations :: (MonadError String m, MonadLoggerIO m) => CSOverlay -> ContentStore -> FSTree -> [Customization] -> m (CSOverlay, FSTree)
+runCustomizations overlay _repo tree customizations = do
+    logDebugN $ T.pack "Running customizations"
+    foldM runCustomization (overlay, tree) customizations
  where
-    runCustomization :: MonadError String m => (CSOverlay, FSTree) -> Customization -> m (CSOverlay, FSTree)
+    runCustomization :: (MonadError String m, MonadLoggerIO m) => (CSOverlay, FSTree) -> Customization -> m (CSOverlay, FSTree)
     runCustomization (o, t) (WriteFile file content) = addToOverlay o t file content
diff --git a/src/BDCS/Export/Directory.hs b/src/BDCS/Export/Directory.hs
--- a/src/BDCS/Export/Directory.hs
+++ b/src/BDCS/Export/Directory.hs
@@ -21,6 +21,7 @@
 import           Control.Conditional(unlessM)
 import           Control.Monad.Except(MonadError, throwError)
 import           Control.Monad.IO.Class(MonadIO, liftIO)
+import           Control.Monad.Logger(MonadLoggerIO)
 import qualified Data.ByteString as BS
 import           Data.Conduit(Consumer, awaitForever)
 import qualified Data.Text as T
@@ -40,7 +41,7 @@
 --
 -- It is expected that the caller will decide whether the destination directory should be empty
 -- or not.  This function does nothing to enforce that.
-directorySink :: (MonadError String m, MonadIO m) => FilePath -> Consumer (Files, CS.Object) m ()
+directorySink :: (MonadError String m, MonadLoggerIO m) => FilePath -> Consumer (Files, CS.Object) m ()
 directorySink outPath = awaitForever $ \case
     (f, CS.SpecialObject) -> checkoutSpecial f
     (f, CS.FileObject bs) -> checkoutFile f bs
diff --git a/src/BDCS/Export/FSTree.hs b/src/BDCS/Export/FSTree.hs
--- a/src/BDCS/Export/FSTree.hs
+++ b/src/BDCS/Export/FSTree.hs
@@ -27,7 +27,7 @@
 import           Control.Conditional(whenM)
 import           Control.Monad(foldM)
 import           Control.Monad.Except(MonadError, throwError)
-import           Control.Monad.State(StateT, evalStateT, get, withStateT)
+import           Control.Monad.State(StateT, evalStateT, gets, withStateT)
 import           Data.Conduit(Sink, Source, yield)
 import qualified Data.Conduit.List as CL
 import           Data.List.Safe(init, last)
@@ -123,7 +123,7 @@
                 -- symlink. Follow it, recurse on the symlink target
                 -- check and increment the symlink level so we don't get stuck in a loop
                 Symlink link -> do
-                    whenM ((>= maxSymlinks) <$> get) $
+                    whenM (gets (>= maxSymlinks)) $
                         throwError $ "Too many levels of symbolic links while resolving " ++ T.unpack (filesPath object)
                     linkZipper <- withStateT (+1) $ resolveSymlink zipper link
                     findDirectory linkZipper xs
diff --git a/src/BDCS/Export/Ostree.hs b/src/BDCS/Export/Ostree.hs
--- a/src/BDCS/Export/Ostree.hs
+++ b/src/BDCS/Export/Ostree.hs
@@ -24,6 +24,7 @@
 import           Control.Monad(void)
 import           Control.Monad.Except(MonadError)
 import           Control.Monad.IO.Class(MonadIO, liftIO)
+import           Control.Monad.Logger(MonadLoggerIO, logDebugN, logInfoN)
 import           Control.Monad.Trans.Resource(MonadResource, runResourceT)
 import           Crypto.Hash(SHA256(..), hashInitWith, hashFinalize, hashUpdate)
 import qualified Data.ByteString as BS (readFile)
@@ -46,6 +47,7 @@
 import           BDCS.Export.Directory(directorySink)
 import           BDCS.Export.Utils(runHacks)
 import           BDCS.Utils.Conduit(awaitWith)
+import           BDCS.Utils.Process(callProcessLogged)
 
 import           Paths_bdcs(getDataFileName)
 
@@ -56,7 +58,7 @@
 -- required to make the destination a valid ostree repo is also done by this function - setting up
 -- symlinks and directories, pruning unneeded directories, installing an initrd, building an
 -- RPM database, and so forth.
-ostreeSink :: (MonadError String m, MonadIO m, MonadResource m) => FilePath -> Consumer (Files, CS.Object) m ()
+ostreeSink :: (MonadError String m, MonadLoggerIO m, MonadResource m) => FilePath -> Consumer (Files, CS.Object) m ()
 ostreeSink outPath = do
     -- While it's possible to copy objects from one OstreeRepo to another, we can't create our own objects, meaning
     -- we can't add the dirtree objects we would need to tie all of the files together. So to export to a new
@@ -66,64 +68,70 @@
     -- (e.g., /lib64/libaudit.so.1)
     dst_repo <- liftIO $ open outPath
 
-    bracketP (createTempDirectory (takeDirectory outPath) "export")
+    void $ bracketP (createTempDirectory (takeDirectory outPath) "export")
         removePathForcibly
         (\tmpDir -> do
             -- Run the sink to export to a directory
+            logDebugN "Exporting to directory"
             directorySink tmpDir
 
-            liftIO $ do
-                -- Add the standard hacks
-                runHacks tmpDir
+            -- Add the standard hacks
+            logDebugN "Running standard hacks"
+            runHacks tmpDir
 
-                -- Compile the locale-archive file
-                let localeDir = tmpDir </> "usr" </> "lib" </> "locale"
-                whenM (doesFileExist $ localeDir </> "locale-archive.tmpl")
-                      (callProcess "chroot" [tmpDir, "/usr/sbin/build-locale-archive"])
+            -- Compile the locale-archive file
+            let localeDir = tmpDir </> "usr" </> "lib" </> "locale"
+            whenM (liftIO $ doesFileExist $ localeDir </> "locale-archive.tmpl") $
+                callProcessLogged "chroot" [tmpDir, "/usr/sbin/build-locale-archive"]
 
-                -- Add the kernel and initramfs
-                installKernelInitrd tmpDir
+            -- Add the kernel and initramfs
+            installKernelInitrd tmpDir
 
-                -- Replace /etc/nsswitch.conf with the altfiles version
-                getDataFileName "data/nsswitch-altfiles.conf" >>= readFile >>= writeFile (tmpDir </> "etc" </> "nsswitch.conf")
+            -- Replace /etc/nsswitch.conf with the altfiles version
+            logDebugN "Modifying /etc files"
+            liftIO $ getDataFileName "data/nsswitch-altfiles.conf" >>= readFile >>= writeFile (tmpDir </> "etc" </> "nsswitch.conf")
 
-                -- Remove the fstab stub
-                removeFile $ tmpDir </> "etc" </> "fstab"
+            -- Remove the fstab stub
+            liftIO $ removeFile $ tmpDir </> "etc" </> "fstab"
 
-                -- Move things around how rpm-ostree wants them
-                renameDirs tmpDir
+            -- Move things around how rpm-ostree wants them
+            liftIO $ renameDirs tmpDir
 
-                -- Enable some systemd service
-                doSystemd tmpDir
+            -- Enable some systemd service
+            logDebugN "Enabling systemd services"
+            doSystemd tmpDir
 
-                -- Convert /var to a tmpfiles entry
-                convertVar tmpDir
+            -- Convert /var to a tmpfiles entry
+            logDebugN "Running tmpfiles and creating symlinks"
+            liftIO $ convertVar tmpDir
 
-                -- Add more tmpfiles entries
-                let tmpfilesDir = tmpDir </> "usr" </> "lib" </> "tmpfiles.d"
-                getDataFileName "data/tmpfiles-ostree.conf" >>= readFile >>= writeFile (tmpfilesDir </> "weldr-ostree.conf")
+            -- Add more tmpfiles entries
+            let tmpfilesDir = tmpDir </> "usr" </> "lib" </> "tmpfiles.d"
+            liftIO $ getDataFileName "data/tmpfiles-ostree.conf" >>= readFile >>= writeFile (tmpfilesDir </> "weldr-ostree.conf")
 
-                -- Replace a bunch of top-level directories with symlinks
-                replaceDirs tmpDir
+            -- Replace a bunch of top-level directories with symlinks
+            liftIO $ replaceDirs tmpDir
 
-                -- Create a /sysroot directory
-                createDirectory (tmpDir </> "sysroot")
+            -- Create a /sysroot directory
+            liftIO $ createDirectory (tmpDir </> "sysroot")
 
-                -- Replace /usr/local with a symlink for some reason
+            -- Replace /usr/local with a symlink for some reason
+            liftIO $ do
                 removePathForcibly $ tmpDir </> "usr" </> "local"
                 createSymbolicLink "../var/usrlocal" $ tmpDir </> "usr" </> "local"
 
-                -- rpm-ostree moves /var/lib/rpm to /usr/share/rpm. We don't have a rpmdb to begin
-                -- with, so create an empty one at /usr/share/rpm.
-                -- rpmdb treats every path as absolute
-                rpmdbDir <- makeAbsolute $ tmpDir </> "usr" </> "share" </> "rpm"
-                createDirectoryIfMissing True rpmdbDir
-                callProcess "rpmdb" ["--initdb", "--dbpath=" ++ rpmdbDir]
+            -- rpm-ostree moves /var/lib/rpm to /usr/share/rpm. We don't have a rpmdb to begin
+            -- with, so create an empty one at /usr/share/rpm.
+            -- rpmdb treats every path as absolute
+            rpmdbDir <- liftIO $ makeAbsolute $ tmpDir </> "usr" </> "share" </> "rpm"
+            liftIO $ createDirectoryIfMissing True rpmdbDir
+            callProcessLogged "rpmdb" ["--initdb", "--dbpath=" ++ rpmdbDir]
 
-                -- import the directory as a new commit
-                void $ withTransaction dst_repo $ \r -> do
-                    f <- storeDirectory r tmpDir
-                    commit r f "Export commit" Nothing)
+            -- import the directory as a new commit
+            logDebugN "Storing results as a new commit"
+            liftIO $ withTransaction dst_repo $ \r -> do
+                f <- storeDirectory r tmpDir
+                commit r f "Export commit" Nothing)
 
     -- Regenerate the summary, necessary for mirroring
     repoRegenerateSummary dst_repo Nothing noCancellable
@@ -183,7 +191,7 @@
 
             yield $ printf "d %s %#o %d %d - -" baseDirPath mode userId groupId
 
-    installKernelInitrd :: FilePath -> IO ()
+    installKernelInitrd :: MonadLoggerIO m => FilePath -> m ()
     installKernelInitrd exportDir = do
         -- The kernel and initramfs need to be named /boot/vmlinuz-<CHECKSUM>
         -- and /boot/initramfs-<CHECKSUM>, where CHECKSUM is the sha256
@@ -192,7 +200,7 @@
         let bootDir = exportDir </> "boot"
 
         -- Find a vmlinuz- file.
-        kernelList <- filter ("vmlinuz-" `isPrefixOf`) <$> listDirectory bootDir
+        kernelList <- filter ("vmlinuz-" `isPrefixOf`) <$> liftIO (listDirectory bootDir)
         let (kernel, kver) = case kernelList of
                                  -- Using fromJust is fine here - we've ensured they all have that
                                  -- prefix with the filter above.
@@ -201,7 +209,14 @@
 
         -- Create the initramfs
         let initramfs = bootDir </> "initramfs-" ++ kver
-        withTempDirectory exportDir "dracut"
+        logInfoN $ "Installing kernel " `T.append` T.pack kernel
+        logInfoN $ "Installing initrd " `T.append` T.pack initramfs
+
+        -- FIXME:  This one isn't getting logged, but withTempDirectory makes that hard.  Using that
+        -- function means scattering MonadMask constraints around in bdcs and bdcs-api, which I'm not
+        -- sure if that's a good thing or not.  Rewriting withTempDirectory to avoid that means using
+        -- bracket and the IO monad, and the types become very complicated.
+        liftIO $ withTempDirectory exportDir "dracut"
             (\tmpDir -> callProcess "chroot"
                 [exportDir,
                  "dracut",
@@ -212,16 +227,16 @@
                  kver])
 
         -- Append the checksum to the filenames
-        kernelData <- BS.readFile kernel
-        initramfsData <- BS.readFile initramfs
+        kernelData <- liftIO $ BS.readFile kernel
+        initramfsData <- liftIO $ BS.readFile initramfs
 
         let ctx = hashInitWith SHA256
         let update1 = hashUpdate ctx kernelData
         let update2 = hashUpdate update1 initramfsData
         let digest = show $ hashFinalize update2
 
-        renameFile kernel (kernel ++ "-" ++ digest)
-        renameFile initramfs (initramfs ++ "-" ++ digest)
+        liftIO $ renameFile kernel (kernel ++ "-" ++ digest)
+        liftIO $ renameFile initramfs (initramfs ++ "-" ++ digest)
 
     renameDirs :: FilePath -> IO ()
     renameDirs exportDir = do
@@ -261,20 +276,21 @@
         createSymbolicLink "var/srv"        (exportDir </> "srv")
         createSymbolicLink "sysroot/tmp"    (exportDir </> "tmp")
 
-    doSystemd :: FilePath -> IO ()
+    doSystemd :: MonadLoggerIO m => FilePath -> m ()
     doSystemd exportDir = do
         let systemdDir = exportDir </> "usr" </> "etc" </> "systemd" </> "system"
-        createDirectoryIfMissing True systemdDir
+        liftIO $ createDirectoryIfMissing True systemdDir
 
         -- Set the default target to multi-user
-        createSymbolicLink "/usr/lib/systemd/system/multi-user.target" $ systemdDir </> "default.target"
+        liftIO $ createSymbolicLink "/usr/lib/systemd/system/multi-user.target" $ systemdDir </> "default.target"
 
         -- Add some services that look important
-        createDirectoryIfMissing True $ systemdDir </> "getty.target.wants"
-        createDirectoryIfMissing True $ systemdDir </> "local-fs.target.wants"
+        liftIO $ do
+            createDirectoryIfMissing True $ systemdDir </> "getty.target.wants"
+            createDirectoryIfMissing True $ systemdDir </> "local-fs.target.wants"
 
-        createSymbolicLink "/usr/lib/systemd/system/getty@.service" $ systemdDir </> "getty.target.wants" </> "getty@tty1.service"
-        createSymbolicLink "/usr/lib/systemd/system/ostree-remount.service" $ systemdDir </> "local-fs.target.wants" </> "ostree-remount.service"
+            createSymbolicLink "/usr/lib/systemd/system/getty@.service" $ systemdDir </> "getty.target.wants" </> "getty@tty1.service"
+            createSymbolicLink "/usr/lib/systemd/system/ostree-remount.service" $ systemdDir </> "local-fs.target.wants" </> "ostree-remount.service"
 
 -- Write the commit metadata object to an opened ostree repo, given the results of calling store.  This
 -- function also requires a commit subject and optionally a commit body.  The subject and body are
diff --git a/src/BDCS/Export/Qcow2.hs b/src/BDCS/Export/Qcow2.hs
--- a/src/BDCS/Export/Qcow2.hs
+++ b/src/BDCS/Export/Qcow2.hs
@@ -1,5 +1,6 @@
 {-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE OverloadedStrings #-}
 
 -- |
 -- Module: BDCS.Export.Qcow2
@@ -16,22 +17,22 @@
  where
 
 import Control.Monad.Except(MonadError)
-import Control.Monad.IO.Class(MonadIO, liftIO)
+import Control.Monad.Logger(MonadLoggerIO, logDebugN)
 import Control.Monad.Trans.Resource(MonadResource)
 import Data.Conduit(Consumer, bracketP)
 import System.Directory(removePathForcibly)
 import System.FilePath(takeDirectory)
 import System.IO.Temp(createTempDirectory)
-import System.Process(callProcess)
 
 import qualified BDCS.CS as CS
 import           BDCS.DB(Files)
 import           BDCS.Export.Directory(directorySink)
 import           BDCS.Export.Utils(runHacks, runTmpfiles)
+import           BDCS.Utils.Process(callProcessLogged)
 
 -- | A 'Consumer' that writes objects into a temporary directory, and then converts that directory into
 -- a qcow2 image with virt-make-fs.
-qcow2Sink :: (MonadResource m, MonadIO m, MonadError String m) => FilePath -> Consumer (Files, CS.Object) m ()
+qcow2Sink :: (MonadError String m, MonadLoggerIO m, MonadResource m) => FilePath -> Consumer (Files, CS.Object) m ()
 qcow2Sink outPath =
     -- Writing and importing a tar file probably will not work, because some rpms contain paths
     -- with symlinks (e.g., /lib64/libaudit.so.1 is expected to be written to /usr/lib64).
@@ -41,14 +42,17 @@
         removePathForcibly
         (\tmpDir -> do
             -- Apply tmpfiles.d to the directory first
-            liftIO $ runTmpfiles tmpDir
+            logDebugN "Running tmpfiles"
+            runTmpfiles tmpDir
 
             -- Run the sink to create a directory export
+            logDebugN "Exporting to directory"
             directorySink tmpDir
 
             -- Make the direcotry export something usable, hopefully
-            liftIO $ runHacks tmpDir
+            logDebugN "Running standard hacks"
+            runHacks tmpDir
 
             -- Run virt-make-fs to generate the qcow2
-            liftIO $ callProcess "virt-make-fs" [tmpDir, outPath, "--format=qcow2", "--label=composer"]
+            callProcessLogged "virt-make-fs" [tmpDir, outPath, "--format=qcow2", "--label=composer"]
         )
diff --git a/src/BDCS/Export/Tar.hs b/src/BDCS/Export/Tar.hs
--- a/src/BDCS/Export/Tar.hs
+++ b/src/BDCS/Export/Tar.hs
@@ -16,7 +16,8 @@
  where
 
 import qualified Codec.Archive.Tar as Tar
-import           Control.Monad.IO.Class(MonadIO, liftIO)
+import           Control.Monad.Logger(MonadLoggerIO)
+import           Control.Monad.IO.Class(liftIO)
 import           Data.ByteString.Lazy(writeFile)
 import           Data.Conduit(Consumer)
 import qualified Data.Conduit.List as CL
@@ -24,7 +25,7 @@
 
 -- | A 'Consumer' that writes objects (in the form of 'Tar.Entry' records) into a tar archive
 -- with the provided name.  To convert objects into an Entry, see 'BDCS.CS.objectToTarEntry'.
-tarSink :: MonadIO m => FilePath -> Consumer Tar.Entry m ()
+tarSink :: MonadLoggerIO m => FilePath -> Consumer Tar.Entry m ()
 tarSink out_path = do
     entries <- CL.consume
     liftIO $ writeFile out_path (Tar.write entries)
diff --git a/src/BDCS/Export/TmpFiles.hs b/src/BDCS/Export/TmpFiles.hs
--- a/src/BDCS/Export/TmpFiles.hs
+++ b/src/BDCS/Export/TmpFiles.hs
@@ -33,6 +33,8 @@
  where
 
 import           Control.Conditional(ifM)
+import           Control.Monad.IO.Class(liftIO)
+import           Control.Monad.Logger(MonadLoggerIO)
 import           Data.List(sort)
 import qualified Data.Text as T
 import           System.Directory(createDirectoryIfMissing, doesPathExist, removePathForcibly)
@@ -193,8 +195,8 @@
 
 -- Create a new directory if there isn't already one present.
 -- Also sets the ownership and permissions
-applyEntry :: FilePath -> TmpFileEntry -> IO ()
-applyEntry outPath TmpFileEntry{tfeType=NewDirectory, ..} = do
+applyEntry :: MonadLoggerIO m => FilePath -> TmpFileEntry -> m ()
+applyEntry outPath TmpFileEntry{tfeType=NewDirectory, ..} = liftIO $ do
     createDirectoryIfMissing True dir
     setFileMode dir mode
     setOwnerAndGroup dir (owner tfeUid) (group tfeGid)
@@ -206,7 +208,7 @@
 
 -- Create a new file with optional contents.
 -- Also sets the ownership and permissions
-applyEntry outPath entry@TmpFileEntry{tfeType=NewFile, ..} =
+applyEntry outPath entry@TmpFileEntry{tfeType=NewFile, ..} = liftIO $
     ifM (doesPathExist file)
         (printf "NewFile: %s already exists, skipping it." file)
         (writeNewFile outPath entry)
@@ -215,10 +217,10 @@
 
 -- Create or Truncate a file with optional contents.
 -- Also sets the ownership and permissions
-applyEntry outPath entry@TmpFileEntry{tfeType=TruncateFile, ..} = writeNewFile outPath entry
+applyEntry outPath entry@TmpFileEntry{tfeType=TruncateFile, ..} = liftIO $ writeNewFile outPath entry
 
 -- Modify an existing directory's ownership and permissions>
-applyEntry outPath TmpFileEntry{tfeType=ModifyDirectory, ..} =
+applyEntry outPath TmpFileEntry{tfeType=ModifyDirectory, ..} = liftIO $
     ifM (doesPathExist dir)
         modify
         (printf "ModifyDirectory: %s doesn't exist, skipping it." dir)
@@ -235,7 +237,7 @@
 -- Create a new symlink.
 -- Does NOT create parents of the source file, they must already exist
 -- If no target arg is present it will link to the source filename under /usr/share/factory/
-applyEntry outPath TmpFileEntry{tfeType=NewSymlink, ..} =
+applyEntry outPath TmpFileEntry{tfeType=NewSymlink, ..} = liftIO $
     ifM (doesPathExist source)
         (printf "NewSymlink: %s exists, skipping." source)
         (createSymbolicLink target source)
@@ -247,7 +249,7 @@
 
 -- Replace a symlink, if it exists or create a new one.
 -- If no target arg is present it will link to the source filename under /usr/share/factory/
-applyEntry outPath TmpFileEntry{tfeType=ReplaceSymlink, ..} = do
+applyEntry outPath TmpFileEntry{tfeType=ReplaceSymlink, ..} = liftIO $ do
     removePathForcibly source
     createSymbolicLink target source
   where
@@ -260,10 +262,10 @@
 
 
 -- | Read the tmpfiles.d snippet and apply it to the output directory
-setupFilesystem :: FilePath -> FilePath -> IO ()
+setupFilesystem :: MonadLoggerIO m => FilePath -> FilePath -> m ()
 setupFilesystem outPath tmpFileConf = do
-    createDirectoryIfMissing True outPath
-    tmpfiles <- parseConfString <$> readFile tmpFileConf
+    liftIO $ createDirectoryIfMissing True outPath
+    tmpfiles <- parseConfString <$> liftIO (readFile tmpFileConf)
     case tmpfiles of
         Right entries -> mapM_ (applyEntry outPath) $ sort entries
-        Left  err     -> print err
+        Left  err     -> liftIO $ print err
diff --git a/src/BDCS/Export/Utils.hs b/src/BDCS/Export/Utils.hs
--- a/src/BDCS/Export/Utils.hs
+++ b/src/BDCS/Export/Utils.hs
@@ -19,64 +19,74 @@
 import           Control.Conditional(whenM)
 import           Control.Exception(tryJust)
 import           Control.Monad(guard)
+import           Control.Monad.IO.Class(liftIO)
+import           Control.Monad.Logger(MonadLoggerIO, logDebugN)
 import           Data.List(intercalate)
 import           Data.List.Split(splitOn)
 import qualified Data.Text as T
 import           System.Directory(createDirectoryIfMissing, doesFileExist, listDirectory, removePathForcibly, renameFile)
 import           System.FilePath((</>))
 import           System.IO.Error(isDoesNotExistError)
-import           System.Process(callProcess)
 
 import BDCS.Export.TmpFiles(setupFilesystem)
+import BDCS.Utils.Process(callProcessLogged)
 
 import Paths_bdcs(getDataFileName)
 
 -- | Run filesystem hacks needed to make a directory tree bootable.  Any exporter that produces a
 -- finished image should call this function.  Otherwise, it is not generally useful and should be
 -- avoided.  The exact hacks required is likely to change over time.
-runHacks :: FilePath -> IO ()
+runHacks :: MonadLoggerIO m => FilePath -> m ()
 runHacks exportPath = do
     -- set a root password
     -- pre-crypted from "redhat"
-    shadowRecs <- map (splitOn ":") <$> lines <$> readFile (exportPath </> "etc" </> "shadow")
-    let newRecs = map (\rec -> case rec of
-                                   "root":_:rest -> ["root", "$6$3VLMX3dyCGRa.JX3$RpveyimtrKjqcbZNTanUkjauuTRwqAVzRK8GZFkEinbjzklo7Yj9Z6FqXNlyajpgCdsLf4FEQQKH6tTza35xs/"] ++ rest
-                                   _             -> rec)
-                      shadowRecs
-    writeFile (exportPath </> "etc" </> "shadow.new") (unlines $ map (intercalate ":") newRecs)
-    renameFile (exportPath </> "etc" </> "shadow.new") (exportPath </> "etc" </> "shadow")
+    logDebugN "Setting root password"
+    liftIO $ do
+        shadowRecs <- map (splitOn ":") <$> lines <$> readFile (exportPath </> "etc" </> "shadow")
+        let newRecs = map (\rec -> case rec of
+                                       "root":_:rest -> ["root", "$6$3VLMX3dyCGRa.JX3$RpveyimtrKjqcbZNTanUkjauuTRwqAVzRK8GZFkEinbjzklo7Yj9Z6FqXNlyajpgCdsLf4FEQQKH6tTza35xs/"] ++ rest
+                                       _             -> rec)
+                          shadowRecs
+        writeFile (exportPath </> "etc" </> "shadow.new") (unlines $ map (intercalate ":") newRecs)
+        renameFile (exportPath </> "etc" </> "shadow.new") (exportPath </> "etc" </> "shadow")
 
     -- create an empty machine-id
-    writeFile (exportPath </> "etc" </> "machine-id") ""
+    logDebugN "Creating empty /etc/machine-id"
+    liftIO $ writeFile (exportPath </> "etc" </> "machine-id") ""
 
     -- Install a sysusers.d config file, and run systemd-sysusers to implement it
     let sysusersDir = exportPath </> "usr" </> "lib" </> "sysusers.d"
-    createDirectoryIfMissing True sysusersDir
-    getDataFileName "data/sysusers-default.conf" >>= readFile >>= writeFile (sysusersDir </> "weldr.conf")
-    callProcess "systemd-sysusers" ["--root", exportPath]
+    liftIO $ do
+        createDirectoryIfMissing True sysusersDir
+        getDataFileName "data/sysusers-default.conf" >>= readFile >>= writeFile (sysusersDir </> "weldr.conf")
 
+    callProcessLogged "systemd-sysusers" ["--root", exportPath]
+
     -- Run depmod on any kernel modules that might be present
     let modDir = exportPath </> "usr" </> "lib" </> "modules"
-    modVers <- tryJust (guard . isDoesNotExistError) (listDirectory modDir)
-    mapM_ (\ver -> callProcess "depmod" ["-b", exportPath, "-a", ver]) $ either (const []) id modVers
+    modVers <- liftIO $ tryJust (guard . isDoesNotExistError) (listDirectory modDir)
+    mapM_ (\ver -> callProcessLogged "depmod" ["-b", exportPath, "-a", ver]) $ either (const []) id modVers
 
     -- Create a fstab stub
-    writeFile (exportPath </> "etc" </> "fstab") "LABEL=composer / ext2 defaults 0 0"
+    logDebugN "Creating stub /etc/fstab"
+    liftIO $ writeFile (exportPath </> "etc" </> "fstab") "LABEL=composer / ext2 defaults 0 0"
 
     -- Clean up /run
     -- Some packages create directories in /var/run, which a symlink to /run, which is a tmpfs.
-    (map ((exportPath </> "run") </>) <$> listDirectory (exportPath </> "run")) >>= mapM_ removePathForcibly
+    logDebugN "Removing directories in /run"
+    liftIO $ (map ((exportPath </> "run") </>) <$> listDirectory (exportPath </> "run")) >>= mapM_ removePathForcibly
 
     -- EXTRA HACKY: turn off mod_ssl
     let sslConf = exportPath </> "etc" </> "httpd" </> "conf.d" </> "ssl.conf"
-    whenM (doesFileExist sslConf)
-          (renameFile sslConf (sslConf ++ ".off"))
+    whenM (liftIO $ doesFileExist sslConf) $ do
+        logDebugN "Disabling mod_ssl"
+        liftIO $ renameFile sslConf (sslConf ++ ".off")
 
 -- | Run tmpfiles.d snippet on the new directory.  Most exporters should call this function.  Otherwise,
 -- it is not generally useful and should be avoided.
-runTmpfiles :: FilePath -> IO ()
+runTmpfiles :: MonadLoggerIO m => FilePath -> m ()
 runTmpfiles exportPath = do
-    configPath <- getDataFileName "data/tmpfiles-default.conf"
+    configPath <- liftIO $ getDataFileName "data/tmpfiles-default.conf"
     setupFilesystem exportPath configPath
 
 -- | List the supported output formats.
diff --git a/src/BDCS/Groups.hs b/src/BDCS/Groups.hs
--- a/src/BDCS/Groups.hs
+++ b/src/BDCS/Groups.hs
@@ -16,6 +16,7 @@
 {-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
 
 module BDCS.Groups(findGroupRequirements,
                    findRequires,
@@ -23,6 +24,8 @@
                    getGroupId,
                    getGroupIdC,
                    getGroup,
+                   getGroupsLike,
+                   getGroupsTotal,
                    getGroupRequirements,
                    getRequirementsForGroup,
                    groups,
@@ -38,6 +41,7 @@
 import           Data.Bifunctor(bimap)
 import           Data.Conduit((.|), Conduit, Source)
 import qualified Data.Conduit.List as CL
+import           Data.Int(Int64)
 import qualified Data.Text as T
 import           Database.Esqueleto
 
@@ -100,6 +104,39 @@
     where_ $ group ^. GroupsId ==. val key
     limit 1
     return group
+
+-- | Get the groups matching a name
+-- Optionally limit the results with limit and offset
+-- Also returns the total number of results, before offset and limit are applied
+getGroupsLike :: MonadIO m => Maybe Int64 -> Maybe Int64 -> T.Text -> SqlPersistT m ([(Key Groups, T.Text)], Int64)
+getGroupsLike (Just ofst) (Just lmt) name = do
+    results <- select $ from $ \group -> do
+               where_ $ group ^. GroupsName `like` val name
+               orderBy [asc (group ^. GroupsName)]
+               offset ofst
+               limit lmt
+               return  (group ^. GroupsId, group ^. GroupsName)
+    total <- firstListResult $
+             select $ from $ \group -> do
+             where_ $ group ^. GroupsName `like` val name
+             return countRows
+    return (map (bimap unValue unValue) results, total)
+
+getGroupsLike _ _ name = do
+    results <- select $ from $ \group -> do
+               where_ $ group ^. GroupsName `like` val name
+               orderBy [asc (group ^. GroupsName)]
+               return  (group ^. GroupsId, group ^. GroupsName)
+    total <- firstListResult $
+             select $ from $ \group -> do
+             where_ $ group ^. GroupsName `like` val name
+             return countRows
+    return (map (bimap unValue unValue) results, total)
+
+-- | Return the total number of groups
+getGroupsTotal :: MonadIO m => SqlPersistT m Int64
+getGroupsTotal = firstListResult $
+    select . from $ \(_ :: SqlExpr (Entity Groups)) -> return countRows
 
 groups :: MonadIO m => SqlPersistT m [(Key Groups, T.Text)]
 groups = do
diff --git a/src/BDCS/Import/NPM.hs b/src/BDCS/Import/NPM.hs
--- a/src/BDCS/Import/NPM.hs
+++ b/src/BDCS/Import/NPM.hs
@@ -21,8 +21,8 @@
 import           Control.Monad.Catch(MonadThrow)
 import           Control.Monad.Except(MonadError, runExceptT, throwError)
 import           Control.Monad.IO.Class(MonadIO, liftIO)
-import           Control.Monad.Reader(ReaderT, ask)
-import           Control.Monad.State(MonadState, get, modify)
+import           Control.Monad.Reader(ReaderT, asks)
+import           Control.Monad.State(MonadState, gets, modify)
 import           Control.Monad.Trans.Resource(MonadBaseControl, MonadResource)
 import           Data.Aeson(FromJSON(..), Object, Value(..), (.:), (.:?), (.!=), eitherDecode, withObject, withText)
 import           Data.Aeson.Types(Parser, typeMismatch)
@@ -253,8 +253,8 @@
 -- screen.
 loadFromURI :: URI -> ReaderT ImportState IO ()
 loadFromURI uri@URI{..} = do
-    db <- stDB <$> ask
-    repo <- stRepo <$> ask
+    db <- asks stDB
+    repo <- asks stRepo
 
     result <- runExceptT $ do
         -- Fetch the JSON describing the package
@@ -356,7 +356,7 @@
         handleHardLink baseFile = do
             -- Use the same ByteString -> FilePath unpacking that headerFilePath uses
             target <- S8.unpack <$> CC.fold
-            (HM.lookup target <$> get) >>=
+            gets (HM.lookup target) >>=
                 maybe (throwError $ "Broken hard link to " ++ target)
                       (\(digest, size) -> return $ baseFile {filesSize      = fromIntegral size,
                                                              filesCs_object = Just $ convert digest})
diff --git a/src/BDCS/Import/RPM.hs b/src/BDCS/Import/RPM.hs
--- a/src/BDCS/Import/RPM.hs
+++ b/src/BDCS/Import/RPM.hs
@@ -30,7 +30,7 @@
 import           Control.Monad(guard, void)
 import           Control.Monad.Except
 import           Control.Monad.IO.Class(liftIO)
-import           Control.Monad.Reader(ReaderT, ask)
+import           Control.Monad.Reader(ReaderT, asks)
 import           Control.Monad.Trans(lift)
 import           Control.Monad.Trans.Control(MonadBaseControl)
 import           Control.Monad.Trans.Resource(MonadResource, MonadThrow)
@@ -197,8 +197,8 @@
 -- screen.
 loadFromURI :: URI -> ReaderT ImportState IO ()
 loadFromURI uri = do
-    db <- stDB <$> ask
-    repo <- stRepo <$> ask
+    db <- asks stDB
+    repo <- asks stRepo
 
     result <- runExceptT $ runConduitRes $
            getFromURI uri
diff --git a/src/BDCS/Import/Repodata.hs b/src/BDCS/Import/Repodata.hs
--- a/src/BDCS/Import/Repodata.hs
+++ b/src/BDCS/Import/Repodata.hs
@@ -21,6 +21,7 @@
 
 import           Control.Applicative((<|>))
 import           Control.Exception(Exception, throw)
+import           Control.Monad(forM_)
 import           Control.Monad.IO.Class(MonadIO)
 import           Control.Monad.Reader(ReaderT)
 import           Control.Monad.Trans.Resource(MonadBaseControl, MonadThrow)
@@ -99,9 +100,7 @@
     -- Try group_gz, then group. If neither exists group will be Nothing, which is fine, just skip it
     let group = extractType repomd "group_gz" <|> extractType repomd "group"
     let groupURI = fmap appendOrThrow group
-    case groupURI of
-        Just u -> Comps.loadFromURI u
-        Nothing -> return ()
+    forM_ groupURI Comps.loadFromURI
 
  where
     appendOrThrow :: T.Text -> URI
diff --git a/src/BDCS/RPM/Groups.hs b/src/BDCS/RPM/Groups.hs
--- a/src/BDCS/RPM/Groups.hs
+++ b/src/BDCS/RPM/Groups.hs
@@ -19,7 +19,7 @@
 import           Control.Conditional((<&&>), whenM)
 import           Control.Monad(forM_, when)
 import           Control.Monad.IO.Class(MonadIO)
-import           Control.Monad.State(State, execState, get, modify)
+import           Control.Monad.State(State, execState, gets, modify)
 import           Data.Bits(testBit)
 import           Data.Maybe(isJust)
 import qualified Data.Text as T
@@ -89,7 +89,7 @@
         when (flags `testBit` 13) (modify (RT.ScriptVerify:))
 
         -- Check for a bare RPMSENSE_INTERP
-        whenM ((null <$> get) <&&> return (flags `testBit` 8)) $ do
+        whenM (gets null <&&> return (flags `testBit` 8)) $ do
             when ((isJust . findTag "PreIn")  tags) (modify (RT.ScriptPre:))
             when ((isJust . findTag "PostIn") tags) (modify (RT.ScriptPost:))
             when ((isJust . findTag "PreUn")  tags) (modify (RT.ScriptPreUn:))
@@ -101,7 +101,7 @@
         when (flags `testBit` 24) (modify (RT.Feature:))
 
         -- If nothing else set, return Runtime
-        whenM (null <$> get) (modify (RT.Runtime:))
+        whenM (gets null) (modify (RT.Runtime:))
 
 withPRCO :: Monad m => T.Text -> [Tag] -> ((Word32, T.Text) -> m a) -> m ()
 withPRCO ty tags fn =
diff --git a/src/BDCS/Utils/Process.hs b/src/BDCS/Utils/Process.hs
new file mode 100644
--- /dev/null
+++ b/src/BDCS/Utils/Process.hs
@@ -0,0 +1,36 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module: BDCS.Utils.Process
+-- Copyright: (c) 2018 Red Hat, Inc.
+-- License: LGPL
+--
+-- Maintainer: https://github.com/weldr
+-- Stability: alpha
+-- Portability: portable
+--
+-- Functions for running external commands with logging.
+
+module BDCS.Utils.Process(callProcessLogged)
+ where
+
+import           Control.Monad.IO.Class(liftIO)
+import           Control.Monad.Logger(MonadLoggerIO, logInfoN, logErrorN)
+import qualified Data.Text as T
+import           GHC.IO.Exception(IOErrorType(..))
+import           System.Exit(ExitCode(..))
+import           System.IO.Error(mkIOError)
+import           System.Process(readProcessWithExitCode)
+
+callProcessLogged :: MonadLoggerIO m => String -> [String] -> m ()
+callProcessLogged cmd args = do
+    logInfoN $ T.intercalate " " $ T.pack cmd : map T.pack args
+    (rc, stdout, stderr) <- liftIO $ readProcessWithExitCode cmd args ""
+    logInfoN $ T.pack stdout
+
+    case rc of
+        ExitFailure x -> do logErrorN $ T.pack stderr
+                            liftIO $ ioError (mkIOError OtherError (cmd ++ unwords args ++ " (" ++ show x ++ ")")
+                                                        Nothing Nothing)
+        _             -> return ()
diff --git a/src/tests/BDCS/GroupsSpec.hs b/src/tests/BDCS/GroupsSpec.hs
--- a/src/tests/BDCS/GroupsSpec.hs
+++ b/src/tests/BDCS/GroupsSpec.hs
@@ -20,7 +20,7 @@
  where
 
 import BDCS.DB(Groups(..))
-import BDCS.Groups(groupIdToNevra, nevraToGroupId)
+import BDCS.Groups(groupIdToNevra, nevraToGroupId, getGroupsLike, getGroupsTotal, groups)
 import BDCS.GroupKeyValue(insertGroupKeyValue)
 import BDCS.KeyType(KeyType(..))
 
@@ -69,6 +69,24 @@
 
     it "groupIdToNevra, no epoch" $
       withNevras (groupIdToNevra $ toSqlKey 2) >>= (`shouldBe` Just "noEpoch-1.0-1.el7.x86_64")
+
+    it "getGroupsLike with % wildcard" $
+      withNevras (getGroupsLike Nothing Nothing "%Epoch%") >>= (`shouldBe` ([(toSqlKey 1, "hasEpoch"), (toSqlKey 2, "noEpoch")], 2))
+
+    it "getGroupsLike without % wildcard returns empty list" $
+      withNevras (getGroupsLike Nothing Nothing "Epoch") >>= (`shouldBe` ([], 0))
+
+    it "getGroupsLike with % and limited to 2" $
+      withNevras (getGroupsLike (Just 0) (Just 1) "%") >>= (`shouldBe` ([(toSqlKey 1, "hasEpoch")], 2))
+
+    it "getGroupsLike with % and offset of 1" $
+      withNevras (getGroupsLike (Just 1) (Just 10) "%") >>= (`shouldBe` ([(toSqlKey 2, "noEpoch")], 2))
+
+    it "groups returns all existing records" $
+      withNevras groups >>= (`shouldBe` [(toSqlKey 1, "hasEpoch"), (toSqlKey 2, "noEpoch")])
+
+    it "getGroupsTotal" $
+      withNevras getGroupsTotal >>= (`shouldBe` 2)
 
  where
     addNevras :: MonadIO m => SqlPersistT m ()
diff --git a/src/tools/bdcs-tmpfiles.hs b/src/tools/bdcs-tmpfiles.hs
--- a/src/tools/bdcs-tmpfiles.hs
+++ b/src/tools/bdcs-tmpfiles.hs
@@ -15,6 +15,7 @@
 -- You should have received a copy of the GNU Lesser General Public
 -- License along with this library; if not, see <http://www.gnu.org/licenses/>.
 
+import Control.Monad.Logger(runNoLoggingT)
 import System.Directory(createDirectoryIfMissing)
 import System.Environment(getArgs)
 import System.Exit(exitFailure)
@@ -33,5 +34,5 @@
 main :: IO ()
 main = getArgs >>= \case
     cfg:dir:_ -> do createDirectoryIfMissing True dir
-                    setupFilesystem dir cfg
+                    runNoLoggingT $ setupFilesystem dir cfg
     _         -> usage
