diff --git a/composer.json b/composer.json
index b56fda0d72e44203d017f351e1ffe2d72f4d2e1d..d138f989cfd986c396e43337211e1ebcd0c8e09a 100644
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,7 @@
         "cap60552/php-sip2": "1.0.0",
         "los/losrecaptcha": "1.0.0",
         "ahand/mobileesp": "dev-master",
+        "matthiasmullie/minify": "1.3.35",
         "ocramius/proxy-manager": "1.0.2",
         "oyejorge/less.php": "1.7.0.9",
         "pear/file_marc": "1.1.2",
diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index bfb9ceaa587d7928dab249236ab3ce94fee4e264..3f32949cdb3e09278b083d15cf75331adc11fa14 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -33,6 +33,22 @@ title           = "Library Catalog"
 ;   bootprint3 = bootstrap3 theme with more attractive default styling applied
 ;                (named after the earlier, now-deprecated blueprint theme)
 theme           = bootprint3
+
+; Automatic asset minification and concatenation setting. When active, HeadScript
+; and HeadLink will concatenate and minify all viable files to reduce requests and
+; load times. This setting is off by default.
+;
+; This configuration takes the form of a semi-colon separated list of
+; environment:configuration pairs where "environment" is a possible APPLICATION_ENV
+; value (e.g. 'production' or 'development') or '*'/no prefix for all contexts.
+; Possible values for 'configuration' within each environment are 'js', 'css',
+; 'off'/false, 'on'/true/'*'. This allows global enabling/disabling of the pipeline
+; or separate configurations for different types of resources. Multiple configuration
+; values may be comma-separated -- e.g. 'js,css'.
+;
+; Example: "development:off; production:js,css"
+;asset_pipeline = "production:js"
+
 ; Uncomment the following line to use a different default theme for mobile devices.
 ; You may not wish to use this setting if you are using one of the Bootstrap-based
 ; standard themes since they support responsive design. Available mobile theme:
diff --git a/config/vufind/httpd-vufind.conf b/config/vufind/httpd-vufind.conf
index fd86c0032b1e252917473c1b8e0b960a9691d4b4..ddbb03a287824c630bafcf56dc6a3a9fc1da8cfe 100644
--- a/config/vufind/httpd-vufind.conf
+++ b/config/vufind/httpd-vufind.conf
@@ -13,6 +13,19 @@ AliasMatch ^/vufind/themes/([0-9a-zA-Z-_]*)/js/(.*)$ /usr/local/vufind/themes/$1
   AllowOverride All
 </Directory>
 
+# Configuration for public cache (used for asset pipeline minification)
+AliasMatch ^/vufind/cache/(.*)$ /usr/local/vufind/local/cache/public/$1
+<Directory /usr/local/vufind/local/cache/public/>
+  <IfModule !mod_authz_core.c>
+    Order allow,deny
+    Allow from all
+  </IfModule>
+  <IfModule mod_authz_core.c>
+    Require all granted
+  </IfModule>
+  AllowOverride All
+</Directory>
+
 # Configuration for general VuFind base:
 Alias /vufind /usr/local/vufind/public
 <Directory /usr/local/vufind/public/>
diff --git a/module/VuFind/src/VuFind/Cache/Manager.php b/module/VuFind/src/VuFind/Cache/Manager.php
index 8947930118ce520f4ec75f2a39dec9a796f56632..7dcd720a2db4eee499c377ac438ea6f7c5b30df6 100644
--- a/module/VuFind/src/VuFind/Cache/Manager.php
+++ b/module/VuFind/src/VuFind/Cache/Manager.php
@@ -92,6 +92,7 @@ class Manager
         foreach (['config', 'cover', 'language', 'object'] as $cache) {
             $this->createFileCache($cache, $cacheBase . $cache . 's');
         }
+        $this->createFileCache('public', $cacheBase . 'public');
 
         // Set up search specs cache based on config settings:
         $searchCacheType = isset($searchConfig->Cache->type)
diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/ConcatTrait.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/ConcatTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..ca338c105219d9b143bf5394f27ed0b750c55fe9
--- /dev/null
+++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/ConcatTrait.php
@@ -0,0 +1,328 @@
+<?php
+/**
+ * Trait to add asset pipeline functionality (concatenation / minification) to
+ * a HeadLink/HeadScript-style view helper.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTheme\View\Helper;
+use VuFindTheme\ThemeInfo;
+
+/**
+ * Trait to add asset pipeline functionality (concatenation / minification) to
+ * a HeadLink/HeadScript-style view helper.
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+trait ConcatTrait
+{
+    /**
+     * Returns true if file should not be included in the compressed concat file
+     *
+     * @param stdClass $item Element object
+     *
+     * @return bool
+     */
+    abstract protected function isExcludedFromConcat($item);
+
+    /**
+     * Get the folder name and file extension
+     *
+     * @return string
+     */
+    abstract protected function getFileType();
+
+    /**
+     * Get the file path from the element object
+     *
+     * @param stdClass $item Element object
+     *
+     * @return string
+     */
+    abstract protected function getResourceFilePath($item);
+
+    /**
+     * Set the file path of the element object
+     *
+     * @param stdClass $item Element object
+     * @param string   $path New path string
+
+     * @return void
+     */
+    abstract protected function setResourceFilePath($item, $path);
+
+    /**
+     * Get the minifier that can handle these file types
+     *
+     * @return minifying object like \MatthiasMullie\Minify\JS
+     */
+    abstract protected function getMinifier();
+
+    /**
+     * Set the file path of the link object
+     *
+     * @param stdClass $item Link element object
+     *
+     * @return string
+     */
+    public function getType($item)
+    {
+        return 'default';
+    }
+
+    /**
+     * Should we use the asset pipeline to join files together and minify them?
+     *
+     * @var bool
+     */
+    protected $usePipeline = false;
+
+    /**
+     * Array of resource items by type, contains key as well
+     *
+     * @var array
+     */
+    protected $groups = [];
+
+    /**
+     * Future order of the concatenated file
+     *
+     * @var number
+     */
+    protected $concatIndex = null;
+
+    /**
+     * Check if config is enamled for this file type
+     *
+     * @param string $config Config for current application environment
+     *
+     * @return boolean
+     */
+    protected function enabledInConfig($config)
+    {
+        if ($config === false || $config == 'off') {
+            return false;
+        }
+        if ($config == '*' || $config == 'on'
+            || $config == 'true' || $config === true
+        ) {
+            return true;
+        }
+        $settings = array_map('trim', explode(',', $config));
+        return in_array($this->getFileType(), $settings);
+    }
+
+    /**
+     * Initialize class properties related to concatenation of resources.
+     * All of the elements to be concatenated into ($this->concatItems)
+     * and those that need to remain on their own ($this->otherItems).
+     *
+     * @return void
+     */
+    protected function filterItems()
+    {
+        $this->groups = [];
+        $groupTypes = [];
+
+        $this->getContainer()->ksort();
+
+        foreach ($this as $key => $item) {
+            if ($this->isExcludedFromConcat($item)) {
+                $this->groups[] = [
+                    'other' => true,
+                    'item' => $item
+                ];
+                $groupTypes[] = 'other';
+                continue;
+            }
+
+            $details = $this->themeInfo->findContainingTheme(
+                $this->getFileType() . '/' . $this->getResourceFilePath($item),
+                ThemeInfo::RETURN_ALL_DETAILS
+            );
+
+            $type = $this->getType($item);
+            $index = array_search($type, $groupTypes);
+            if ($index === false) {
+                $this->groups[] = [
+                    'items' => [$item],
+                    'key' => $details['path'] . filemtime($details['path'])
+                ];
+                $groupTypes[] = $type;
+            } else {
+                $this->groups[$index]['items'][] = $item;
+                $this->groups[$index]['key'] .=
+                    $details['path'] . filemtime($details['path']);
+            }
+        }
+
+        return count($groupTypes) > 0;
+    }
+
+    /**
+     * Get the path to the directory where we can cache files generated by
+     * this trait.
+     *
+     * @return string
+     */
+    protected function getResourceCacheDir()
+    {
+        return $this->themeInfo->getBaseDir() . '/../local/cache/public/';
+    }
+
+    /**
+     * Using the concatKey, return the path of the concatenated file.
+     * Generate if it does not yet exist.
+     *
+     * @param array $group Object containing 'key' and stdobj file 'items'
+     *
+     * @return string
+     */
+    protected function getConcatenatedFilePath($group)
+    {
+        $urlHelper = $this->getView()->plugin('url');
+
+        // Don't recompress individual files
+        if (count($group['items']) === 1) {
+            $path = $this->getResourceFilePath($group['items'][0]);
+            $details = $this->themeInfo->findContainingTheme(
+                $this->getFileType() . '/' . $path,
+                ThemeInfo::RETURN_ALL_DETAILS
+            );
+            return $urlHelper('home') . 'themes/' . $details['theme']
+                . '/' . $this->getFileType() . '/' . $path;
+        }
+        // Locate/create concatenated asset file
+        $filename = md5($group['key']) . '.min.' . $this->getFileType();
+        $concatPath = $this->getResourceCacheDir() . $filename;
+        if (!file_exists($concatPath)) {
+            $minifier = $this->getMinifier();
+            foreach ($group['items'] as $item) {
+                $details = $this->themeInfo->findContainingTheme(
+                    $this->getFileType() . '/' . $this->getResourceFilePath($item),
+                    ThemeInfo::RETURN_ALL_DETAILS
+                );
+                $minifier->add($details['path']);
+            }
+            $minifier->minify($concatPath);
+        }
+
+        return $urlHelper('home') . 'cache/' . $filename;
+    }
+
+    /**
+     * Process and return items in index order
+     *
+     * @param string|int $indent Amount of whitespace/string to use for indention
+     *
+     * @return string
+     */
+    protected function outputInOrder($indent)
+    {
+        // Some of this logic was copied from HeadScript; it does not all apply
+        // when incorporated into HeadLink, but it has no harmful side effects.
+        $indent = (null !== $indent)
+            ? $this->getWhitespace($indent)
+            : $this->getIndent();
+
+        if ($this->view) {
+            $useCdata = $this->view->plugin('doctype')->isXhtml();
+        } else {
+            $useCdata = $this->useCdata;
+        }
+
+        $escapeStart = ($useCdata) ? '//<![CDATA[' : '//<!--';
+        $escapeEnd   = ($useCdata) ? '//]]>' : '//-->';
+
+        $output = [];
+        foreach ($this->groups as $group) {
+            if (isset($group['other'])) {
+                $output[] = $this->itemToString(
+                    $group['item'], $indent, $escapeStart, $escapeEnd
+                );
+            } else {
+                // Note that we  use parent::itemToString() below instead of
+                // $this->itemToString() to bypass VuFind logic that determines
+                // file paths within the theme (not appropriate for concatenated
+                // files, which are stored in a theme-independent cache).
+                $path = $this->getConcatenatedFilePath($group);
+                $item = $this->setResourceFilePath($group['items'][0], $path);
+                $output[] = parent::itemToString(
+                    $item, $indent, $escapeStart, $escapeEnd
+                );
+            }
+        }
+
+        return $indent . implode(
+            $this->escape($this->getSeparator()) . $indent, $output
+        );
+    }
+
+    /**
+     * Can we use the asset pipeline?
+     *
+     * @return bool
+     */
+    protected function isPipelineActive()
+    {
+        if ($this->usePipeline) {
+            $cacheDir = $this->getResourceCacheDir();
+            if (!is_writable($cacheDir)) {
+                $this->usePipeline = false;
+                error_log("Cannot write to $cacheDir; disabling asset pipeline.");
+            }
+        }
+        return $this->usePipeline;
+    }
+
+    /**
+     * Render link elements as string
+     * Customized to minify and concatenate
+     *
+     * @param string|int $indent Amount of whitespace or string to use for indention
+     *
+     * @return string
+     */
+    public function toString($indent = null)
+    {
+        // toString must not throw exception
+        try {
+            if (!$this->isPipelineActive() || !$this->filterItems()
+                || count($this) == 1
+            ) {
+                return parent::toString($indent);
+            }
+
+            return $this->outputInOrder($indent);
+        } catch (\Exception $e) {
+            error_log($e->getMessage());
+        }
+
+        return '';
+    }
+}
diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/Factory.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/Factory.php
index 7570fb5d03dc8cb79a76ed079fc027d54c52ffff..761b6776a55424aeea59ded4bd1c43c9579f2d7e 100644
--- a/module/VuFindTheme/src/VuFindTheme/View/Helper/Factory.php
+++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/Factory.php
@@ -41,6 +41,36 @@ use Zend\ServiceManager\ServiceManager;
  */
 class Factory
 {
+    /**
+     * Split config and return prefixed setting with current environment.
+     *
+     * @param ServiceManager $sm Service manager.
+     *
+     * @return HeadLink
+     */
+    protected static function getPipelineConfig(ServiceManager $sm)
+    {
+        $config = $sm->getServiceLocator()->get('VuFind\Config')->get('config');
+        $default = false;
+        if (isset($config['Site']['asset_pipeline'])) {
+            $settings = array_map(
+                'trim',
+                explode(';', $config['Site']['asset_pipeline'])
+            );
+            foreach ($settings as $setting) {
+                $parts = array_map('trim', explode(':', $setting));
+                if (APPLICATION_ENV === $parts[0]) {
+                    return $parts[1];
+                } else if (count($parts) == 1) {
+                    $default = $parts[0];
+                } else if ($parts[0] === '*') {
+                    $default = $parts[1];
+                }
+            }
+        }
+        return $default;
+    }
+
     /**
      * Construct the HeadLink helper.
      *
@@ -51,7 +81,8 @@ class Factory
     public static function getHeadLink(ServiceManager $sm)
     {
         return new HeadLink(
-            $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo')
+            $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo'),
+            Factory::getPipelineConfig($sm)
         );
     }
 
@@ -65,7 +96,8 @@ class Factory
     public static function getHeadScript(ServiceManager $sm)
     {
         return new HeadScript(
-            $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo')
+            $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo'),
+            Factory::getPipelineConfig($sm)
         );
     }
 
@@ -107,7 +139,8 @@ class Factory
     public static function getInlineScript(ServiceManager $sm)
     {
         return new InlineScript(
-            $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo')
+            $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo'),
+            Factory::getPipelineConfig($sm)
         );
     }
 
diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadLink.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadLink.php
index 95344400b6dfc92660a21fd985e2036f1ddcd0ae..7eb38fb4fcddff2198e71d21ba91d4f2922284ba 100644
--- a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadLink.php
+++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadLink.php
@@ -39,6 +39,8 @@ use VuFindTheme\ThemeInfo;
  */
 class HeadLink extends \Zend\View\Helper\HeadLink
 {
+    use ConcatTrait;
+
     /**
      * Theme information service
      *
@@ -50,11 +52,23 @@ class HeadLink extends \Zend\View\Helper\HeadLink
      * Constructor
      *
      * @param ThemeInfo $themeInfo Theme information service
+     * @param string    $plconfig  Config for current application environment
      */
-    public function __construct(ThemeInfo $themeInfo)
+    public function __construct(ThemeInfo $themeInfo, $plconfig = false)
     {
         parent::__construct();
         $this->themeInfo = $themeInfo;
+        $this->usePipeline = $this->enabledInConfig($plconfig);
+    }
+
+    /**
+     * Folder name and file extension for trait
+     *
+     * @return string
+     */
+    protected function getFileType()
+    {
+        return 'css';
     }
 
     /**
@@ -85,15 +99,12 @@ class HeadLink extends \Zend\View\Helper\HeadLink
     /**
      * Compile a less file to css and add to css folder
      *
-     * @param string $file                  Path to less file
-     * @param string $media                 Media type
-     * @param string $conditionalStylesheet Load condition for file
+     * @param string $file Path to less file
      *
-     * @return void
+     * @return string
      */
-    public function addLessStylesheet($file, $media = 'all',
-        $conditionalStylesheet = false
-    ) {
+    public function addLessStylesheet($file)
+    {
         $relPath = 'less/' . $file;
         $urlHelper = $this->getView()->plugin('url');
         $currentTheme = $this->themeInfo->findContainingTheme($relPath);
@@ -122,15 +133,76 @@ class HeadLink extends \Zend\View\Helper\HeadLink
                     'output' => str_replace('.less', '.css', $file)
                 ]
             );
-            $this->prependStylesheet(
-                $cssDirectory . $css_file_name, $media, $conditionalStylesheet
-            );
+            return $cssDirectory . $css_file_name;
         } catch (\Exception $e) {
             error_log($e->getMessage());
             list($fileName, ) = explode('.', $file);
-            $this->prependStylesheet(
-                $urlHelper('home') . "themes/{$currentTheme}/css/{$fileName}.css"
-            );
+            return $urlHelper('home') . "themes/{$currentTheme}/css/{$fileName}.css";
         }
     }
+
+    /**
+     * Returns true if file should not be included in the compressed concat file
+     * Required by ConcatTrait
+     *
+     * @param stdClass $item Link element object
+     *
+     * @return bool
+     */
+    protected function isExcludedFromConcat($item)
+    {
+        return !isset($item->rel) || $item->rel != 'stylesheet'
+            || strpos($item->href, '://');
+    }
+
+    /**
+     * Get the file path from the link object
+     * Required by ConcatTrait
+     *
+     * @param stdClass $item Link element object
+     *
+     * @return string
+     */
+    protected function getResourceFilePath($item)
+    {
+        return $item->href;
+    }
+
+    /**
+     * Set the file path of the link object
+     * Required by ConcatTrait
+     *
+     * @param stdClass $item Link element object
+     * @param string   $path New path string
+     *
+     * @return stdClass
+     */
+    protected function setResourceFilePath($item, $path)
+    {
+        $item->href = $path;
+        return $item;
+    }
+
+    /**
+     * Get the file type
+     *
+     * @param stdClass $item Link element object
+     *
+     * @return string
+     */
+    public function getType($item)
+    {
+        return isset($item->media) ? $item->media : 'all';
+    }
+
+    /**
+     * Get the minifier that can handle these file types
+     * Required by ConcatTrait
+     *
+     * @return \MatthiasMullie\Minify\JS
+     */
+    protected function getMinifier()
+    {
+        return new \MatthiasMullie\Minify\CSS();
+    }
 }
diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadScript.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadScript.php
index e97b7caccf8ff2d1fca72b3ea0e138f2c26c2f3e..9ee6fdd0914264e74cffe7696f464264e91d7412 100644
--- a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadScript.php
+++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadScript.php
@@ -39,6 +39,8 @@ use VuFindTheme\ThemeInfo;
  */
 class HeadScript extends \Zend\View\Helper\HeadScript
 {
+    use ConcatTrait;
+
     /**
      * Theme information service
      *
@@ -50,11 +52,23 @@ class HeadScript extends \Zend\View\Helper\HeadScript
      * Constructor
      *
      * @param ThemeInfo $themeInfo Theme information service
+     * @param boolean   $plconfig  Whether or not to concatenate
      */
-    public function __construct(ThemeInfo $themeInfo)
+    public function __construct(ThemeInfo $themeInfo, $plconfig = false)
     {
         parent::__construct();
         $this->themeInfo = $themeInfo;
+        $this->usePipeline = $this->enabledInConfig($plconfig);
+    }
+
+    /**
+     * Folder name and file extension for trait
+     *
+     * @return string
+     */
+    protected function getFileType()
+    {
+        return 'js';
     }
 
     /**
@@ -86,4 +100,58 @@ class HeadScript extends \Zend\View\Helper\HeadScript
 
         return parent::itemToString($item, $indent, $escapeStart, $escapeEnd);
     }
+
+    /**
+     * Returns true if file should not be included in the compressed concat file
+     * Required by ConcatTrait
+     *
+     * @param stdClass $item Script element object
+     *
+     * @return bool
+     */
+    protected function isExcludedFromConcat($item)
+    {
+        return empty($item->attributes['src'])
+            || isset($item->attributes['conditional'])
+            || strpos($item->attributes['src'], '://');
+    }
+
+    /**
+     * Get the file path from the script object
+     * Required by ConcatTrait
+     *
+     * @param stdClass $item Script element object
+     *
+     * @return string
+     */
+    protected function getResourceFilePath($item)
+    {
+        return $item->attributes['src'];
+    }
+
+    /**
+     * Set the file path of the script object
+     * Required by ConcatTrait
+     *
+     * @param stdClass $item Script element object
+     * @param string   $path New path string
+     *
+     * @return stdClass
+     */
+    protected function setResourceFilePath($item, $path)
+    {
+        $item->attributes['src'] = $path;
+        return $item;
+    }
+
+    /**
+     * Get the minifier that can handle these file types
+     * Required by ConcatTrait
+     *
+     * @return \MatthiasMullie\Minify\JS
+     */
+    protected function getMinifier()
+    {
+        return new \MatthiasMullie\Minify\JS();
+    }
 }
diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadThemeResources.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadThemeResources.php
index d8995883b8e75f623d715e389008da79a6944ad4..078398183ba9a4d58be644658c904f17f062a1f2 100644
--- a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadThemeResources.php
+++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadThemeResources.php
@@ -113,8 +113,8 @@ class HeadThemeResources extends \Zend\View\Helper\AbstractHelper
         // theme resources should load before extras added by individual templates):
         foreach (array_reverse($this->container->getLessCss()) as $current) {
             $parts = explode(':', $current);
-            $headLink()->addLessStylesheet(
-                trim($parts[0]),
+            $headLink()->prependStylesheet(
+                $headLink()->addLessStylesheet(trim($parts[0])),
                 isset($parts[1]) ? trim($parts[1]) : 'all',
                 isset($parts[2]) ? trim($parts[2]) : false
             );