diff --git a/.travis.yml b/.travis.yml
index 2f5ad1e965dfd39f83d2b2eb5860e6c2d9738ffa..89ed1f7639c3ade5d48a796cb05495e371b060db 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -25,6 +25,7 @@ cache:
   directories:
     - node_modules
     - vendor/bundle
+    - "$HOME/google-cloud-sdk"
 env:
   global:
     - NPM_CONFIG_PROGRESS="false"
diff --git a/Gruntfile.js b/Gruntfile.js
index 065fea7746c13f6036f86b36b160623239cc772c..69dfa3873ea0c8cc3ee5c364e45dfb0ff4cf3640 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -277,6 +277,9 @@ module.exports = function (grunt) {
       },
       'postcss-docs': {
         command: 'npm run postcss-docs'
+      },
+      'upload-preview': {
+        command: './grunt/upload-preview.sh'
       }
     },
 
@@ -351,12 +354,13 @@ module.exports = function (grunt) {
   // Only run Sauce Labs tests if there's a Sauce access key
   if (typeof process.env.SAUCE_ACCESS_KEY !== 'undefined' &&
       // Skip Sauce if running a different subset of the test suite
-      runSubset('sauce-js-unit') &&
-      // Skip Sauce on Travis when [skip sauce] is in the commit message
-      isUndefOrNonZero(process.env.TWBS_DO_SAUCE)) {
-    testSubtasks.push('babel:dev');
-    testSubtasks.push('connect');
-    testSubtasks.push('saucelabs-qunit');
+      runSubset('sauce-js-unit')) {
+    testSubtasks = testSubtasks.concat(['dist', 'docs-css', 'docs-js', 'clean:docs', 'copy:docs', 'exec:upload-preview']);
+    // Skip Sauce on Travis when [skip sauce] is in the commit message
+    if (isUndefOrNonZero(process.env.TWBS_DO_SAUCE)) {
+      testSubtasks.push('connect');
+      testSubtasks.push('saucelabs-qunit');
+    }
   }
   grunt.registerTask('test', testSubtasks);
 
diff --git a/grunt/gcp-key.json.enc b/grunt/gcp-key.json.enc
new file mode 100644
index 0000000000000000000000000000000000000000..6e1856a2f1c95b55f73101734af73272433bdee8
Binary files /dev/null and b/grunt/gcp-key.json.enc differ
diff --git a/grunt/upload-preview.sh b/grunt/upload-preview.sh
new file mode 100755
index 0000000000000000000000000000000000000000..905d716abdf0fec7d91263311f596aaa82701dfc
--- /dev/null
+++ b/grunt/upload-preview.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+# Upload built docs to preview.twbsapps.com
+
+if [ "$TRAVIS_REPO_SLUG" != twbs-savage/bootstrap ]; then exit 0; fi
+
+# Add build metadata to version
+sed -i "/^current_version:/ s/\$/+pr.${TRAVIS_COMMIT}/" _config.yml
+# Fix URLs since the site's root is now a subdirectory
+echo "baseurl: /c/${TRAVIS_COMMIT}" >> _config.yml
+bundle exec jekyll build --destination "$TRAVIS_COMMIT"
+
+# Install gcloud & gsutil
+GSUTIL_VERSION=$(gsutil version | cut -d ' ' -f 3)
+if [ ! -d "${HOME}/google-cloud-sdk" ] || [ "${GSUTIL_VERSION}" != '4.19' ]; then
+  rm -rf "${HOME}/google-cloud-sdk" # Kill Travis' outdated non-updateable preinstalled version
+  echo 'Installing google-cloud-sdk...'
+  export CLOUDSDK_CORE_DISABLE_PROMPTS=1
+  time (curl -S -s https://sdk.cloud.google.com | bash &>/dev/null)
+  echo 'Done.'
+fi
+source "${HOME}/google-cloud-sdk/path.bash.inc"
+
+openssl aes-256-cbc -K $encrypted_2b749c8e6327_key -iv $encrypted_2b749c8e6327_iv -in grunt/gcp-key.json.enc -out grunt/gcp-key.json -d
+gcloud auth activate-service-account "$GCP_SERVICE_ACCOUNT" --key-file grunt/gcp-key.json &> /dev/null || (echo 'GCP login failed!'; exit 1)
+
+echo "Uploading to http://preview.twbsapps.com/c/${TRAVIS_COMMIT} ..."
+time gsutil -q -m cp -z html,css,js,svg -r "./${TRAVIS_COMMIT}" gs://preview.twbsapps.com/c/
+echo 'Done.'