diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Revision history for ogma-extra
 
+## [1.5.0] - 2024-11-21
+
+* Version bump 1.5.0 (#178).
+* Introduce template expansion functionality (#162).
+
 ## [1.4.1] - 2024-09-21
 
 * Version bump 1.4.1 (#155).
diff --git a/ogma-extra.cabal b/ogma-extra.cabal
--- a/ogma-extra.cabal
+++ b/ogma-extra.cabal
@@ -32,7 +32,7 @@
 build-type:          Simple
 
 name:                ogma-extra
-version:             1.4.1
+version:             1.5.0
 homepage:            https://github.com/nasa/ogma
 license:             OtherLicense
 license-file:        LICENSE.pdf
@@ -68,11 +68,14 @@
     System.Directory.Extra
 
   build-depends:
-      base       >= 4.11.0.0 && < 5
+      base        >= 4.11.0.0 && < 5
+    , aeson       >= 2.0.0.0  && < 2.2
     , bytestring
     , Cabal
     , directory
     , filepath
+    , microstache >= 1.0      && < 1.1
+    , text        >= 1.2.3.1  && < 2.1
 
   hs-source-dirs:
     src
diff --git a/src/System/Directory/Extra.hs b/src/System/Directory/Extra.hs
--- a/src/System/Directory/Extra.hs
+++ b/src/System/Directory/Extra.hs
@@ -1,3 +1,4 @@
+{-# LANGUAGE OverloadedStrings #-}
 -- Copyright 2020 United States Government as represented by the Administrator
 -- of the National Aeronautics and Space Administration. All Rights Reserved.
 --
@@ -32,17 +33,28 @@
 module System.Directory.Extra
     ( copyDirectoryRecursive
     , copyFile'
+    , copyTemplate
     )
   where
 
 -- External imports
+import           Control.Monad             ( filterM, forM_ )
 import qualified Control.Exception         as E
+import           Data.Aeson                ( Value (..) )
+import qualified Data.ByteString.Lazy      as B
+import           Data.Text.Lazy            ( pack, unpack )
+import           Data.Text.Lazy.Encoding   ( encodeUtf8 )
 import           Distribution.Simple.Utils ( getDirectoryContentsRecursive )
 import           System.Directory          ( copyFile,
-                                             createDirectoryIfMissing )
+                                             createDirectoryIfMissing,
+                                             doesFileExist )
 import           System.Exit               ( ExitCode (ExitFailure), exitWith )
-import           System.FilePath           ( takeDirectory, (</>) )
+import           System.FilePath           ( makeRelative, splitFileName,
+                                             takeDirectory, (</>) )
 import           System.IO                 ( hPutStrLn, stderr )
+import           Text.Microstache          ( compileMustacheFile,
+                                             compileMustacheText,
+                                             renderMustache )
 
 -- | Copy all files from one directory to another.
 copyDirectoryRecursive :: FilePath  -- ^ Source directory
@@ -79,3 +91,48 @@
   hPutStrLn stderr $
     "ogma: error: cannot copy " ++ sourceDir ++ " to " ++ targetDir
   exitWith (ExitFailure 1)
+
+-- * Generic template handling
+
+-- | Copy a template directory into a target location, expanding variables
+-- provided in a map in a JSON value, both in the file contents and in the
+-- filepaths themselves.
+copyTemplate :: FilePath -> Value -> FilePath -> IO ()
+copyTemplate templateDir subst targetDir = do
+
+  -- Get all files (not directories) in the template dir. To keep a directory,
+  -- create an empty file in it (e.g., .keep).
+  tmplContents <- map (templateDir </>) . filter (`notElem` ["..", "."])
+                    <$> getDirectoryContentsRecursive templateDir
+  tmplFiles <- filterM doesFileExist tmplContents
+
+  -- Copy files to new locations, expanding their name and contents as
+  -- mustache templates.
+  forM_ tmplFiles $ \fp -> do
+
+    -- New file name in target directory, treating file
+    -- name as mustache template.
+    let fullPath = targetDir </> newFP
+          where
+            -- If file name has mustache markers, expand, otherwise use
+            -- relative file path
+            newFP = either (const relFP)
+                           (unpack . (`renderMustache` subst))
+                           fpAsTemplateE
+
+            -- Local file name within template dir
+            relFP = makeRelative templateDir fp
+
+            -- Apply mustache substitutions to file name
+            fpAsTemplateE = compileMustacheText "fp" (pack relFP)
+
+    -- File contents, treated as a mustache template.
+    contents <- encodeUtf8 <$> (`renderMustache` subst)
+                           <$> compileMustacheFile fp
+
+    -- Create target directory if necessary
+    let dirName = fst $ splitFileName fullPath
+    createDirectoryIfMissing True dirName
+
+    -- Write expanded contents to expanded file path
+    B.writeFile fullPath contents
