diff --git a/.dockerignore b/.dockerignore
index 90663e2e59..a21fd2409f 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,6 +3,7 @@
 !package.json
 !yarn.lock
 !tsconfig.json
+!tsconfig-ext.json
 !stubs
 !app
 !buildtools
@@ -12,3 +13,4 @@
 !sandbox
 !plugins
 !test
+!ext
diff --git a/Dockerfile b/Dockerfile
index cf479c8387..63a792967a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,11 @@
+################################################################################
+## The Grist source can be extended. This is a stub that can be overridden
+## from command line, as:
+##   docker buildx build -t ... --build-context=ext=<path> .
+## The code in <path> will then be built along with the rest of Grist.
+################################################################################
+FROM scratch as ext
+
 ################################################################################
 ## Javascript build stage
 ################################################################################
@@ -5,17 +13,25 @@
 FROM node:14-buster as builder
 
 # Install all node dependencies.
-ADD package.json package.json
-ADD yarn.lock yarn.lock
-RUN yarn install --frozen-lockfile
+WORKDIR /grist
+COPY package.json yarn.lock /grist/
+RUN yarn install --frozen-lockfile --verbose
+
+# Install any extra node dependencies (at root level, to avoid having to wrestle
+# with merging them).
+COPY --from=ext / /grist/ext
+RUN \
+ mkdir /node_modules && \
+ cd /grist/ext && \
+ { if [ -e package.json ] ; then yarn install --frozen-lockfile --modules-folder=/node_modules --verbose ; fi }
 
 # Build node code.
-ADD tsconfig.json tsconfig.json
-ADD app app
-ADD stubs stubs
-ADD buildtools buildtools
-ADD static static
-ADD test/tsconfig.json test/tsconfig.json
+COPY tsconfig.json /grist
+COPY tsconfig-ext.json /grist
+COPY test/tsconfig.json /grist/test/tsconfig.json
+COPY app /grist/app
+COPY stubs /grist/stubs
+COPY buildtools /grist/buildtools
 RUN yarn run build:prod
 
 ################################################################################
@@ -63,9 +79,10 @@ RUN \
 RUN mkdir -p /persist/docs
 
 # Copy node files.
-COPY --from=builder /node_modules node_modules
-COPY --from=builder /_build _build
-COPY --from=builder /static static
+COPY --from=builder /node_modules /node_modules
+COPY --from=builder /grist/node_modules /grist/node_modules
+COPY --from=builder /grist/_build /grist/_build
+COPY --from=builder /grist/static /grist/static-built
 
 # Copy python files.
 COPY --from=collector /usr/bin/python2.7 /usr/bin/python2.7
@@ -84,11 +101,19 @@ RUN \
 COPY --from=sandbox /runsc /usr/bin/runsc
 
 # Add files needed for running server.
-ADD package.json package.json
-ADD ormconfig.js ormconfig.js
-ADD bower_components bower_components
-ADD sandbox sandbox
-ADD plugins plugins
+ADD package.json /grist/package.json
+ADD ormconfig.js /grist/ormconfig.js
+ADD bower_components /grist/bower_components
+ADD sandbox /grist/sandbox
+ADD plugins /grist/plugins
+ADD static /grist/static
+
+# Finalize static directory
+RUN \
+  mv /grist/static-built/* /grist/static && \
+  rmdir /grist/static-built
+
+WORKDIR /grist
 
 # Set some default environment variables to give a setup that works out of the box when
 # started as:
diff --git a/README.md b/README.md
index fd7735f27f..bff6ecd53f 100644
--- a/README.md
+++ b/README.md
@@ -52,8 +52,8 @@ Here are some specific feature highlights of Grist:
     - Useful for intranet operation and specific compliance requirements.
   * Sandboxing options for untrusted documents.
     - On Linux or with docker, you can enable
-	  [gVisor](https://github.com/google/gvisor) sandboxing at the individual
-	  document level.
+      [gVisor](https://github.com/google/gvisor) sandboxing at the individual
+      document level.
     - On OSX, you can use native sandboxing.
 
 If you are curious about where Grist is going heading,
@@ -268,5 +268,7 @@ GRIST_TEST_ROUTER | if set, then the home server will serve a mock version of ro
 
 This repository, `grist-core`, is released under the [Apache License, Version
 2.0](http://www.apache.org/licenses/LICENSE-2.0), which is an
-[OSI](https://opensource.org/)-approved free software license. See LICENSE.txt and NOTICE.txt for
-more information.
+[OSI](https://opensource.org/)-approved free software license.
+See LICENSE.txt and NOTICE.txt for more information.
+If you have received a version of Grist with an `ext` directory,
+the material within it is separately licensed.
diff --git a/app/server/lib/ExternalStorage.ts b/app/server/lib/ExternalStorage.ts
index 951cc1ee9f..3c45690945 100644
--- a/app/server/lib/ExternalStorage.ts
+++ b/app/server/lib/ExternalStorage.ts
@@ -366,3 +366,38 @@ export interface PropStorage {
 }
 
 export const Unchanged = Symbol('Unchanged');
+
+export interface ExternalStorageSettings {
+  purpose: 'doc' | 'meta';
+  basePrefix?: string;
+  extraPrefix?: string;
+}
+
+/**
+ * The storage mapping we use for our SaaS. A reasonable default, but relies
+ * on appropriate lifecycle rules being set up in the bucket.
+ */
+export function getExternalStorageKeyMap(settings: ExternalStorageSettings): (docId: string) => string {
+  const {basePrefix, extraPrefix, purpose} = settings;
+  let fullPrefix = basePrefix + (basePrefix?.endsWith('/') ? '' : '/');
+  if (extraPrefix) {
+    fullPrefix += extraPrefix + (extraPrefix.endsWith('/') ? '' : '/');
+  }
+
+  // Set up how we name files/objects externally.
+  let fileNaming: (docId: string) => string;
+  if (purpose === 'doc') {
+    fileNaming = docId => `${docId}.grist`;
+  } else if (purpose === 'meta') {
+    // Put this in separate prefix so a lifecycle rule can prune old versions of the file.
+    // Alternatively, could go in separate bucket.
+    fileNaming = docId => `assets/unversioned/${docId}/meta.json`;
+  } else {
+    throw new Error('create.ExternalStorage: unrecognized purpose');
+  }
+  return docId => (fullPrefix + fileNaming(docId));
+}
+
+export function wrapWithKeyMappedStorage(rawStorage: ExternalStorage, settings: ExternalStorageSettings) {
+  return new KeyMappedExternalStorage(rawStorage, getExternalStorageKeyMap(settings));
+}
diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index dae23f2f61..7f1492f541 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -1005,7 +1005,7 @@ export class FlexServer implements GristServer {
       const workers = this._docWorkerMap;
       const docWorkerId = await this._addSelfAsWorker(workers);
 
-      const storageManager = new HostedStorageManager(this.docsRoot, docWorkerId, this._disableS3, '', workers,
+      const storageManager = new HostedStorageManager(this.docsRoot, docWorkerId, this._disableS3, workers,
                                                       this._dbManager, this.create);
       this._storageManager = storageManager;
     } else {
diff --git a/app/server/lib/HostedStorageManager.ts b/app/server/lib/HostedStorageManager.ts
index c76162dbad..7649dba375 100644
--- a/app/server/lib/HostedStorageManager.ts
+++ b/app/server/lib/HostedStorageManager.ts
@@ -53,7 +53,7 @@ export interface HostedStorageOptions {
   // which may then be wrapped in additional layer(s) of ExternalStorage.
   // See ICreate.ExternalStorage.
   // Uses S3 by default in hosted Grist.
-  innerExternalStorageCreate?: (bucket: string) => ExternalStorage;
+  externalStorageCreator?: (purpose: 'doc'|'meta') => ExternalStorage;
 }
 
 const defaultOptions: HostedStorageOptions = {
@@ -127,16 +127,15 @@ export class HostedStorageManager implements IDocStorageManager {
     private _docsRoot: string,
     private _docWorkerId: string,
     private _disableS3: boolean,
-    extraS3Prefix: string,
     private _docWorkerMap: IDocWorkerMap,
     dbManager: HomeDBManager,
     create: ICreate,
     options: HostedStorageOptions = defaultOptions
   ) {
+    const creator = options.externalStorageCreator || ((purpose) => create.ExternalStorage(purpose, ''));
     // We store documents either in a test store, or in an s3 store
     // at s3://<s3Bucket>/<s3Prefix><docId>.grist
-    const externalStoreDoc = this._disableS3 ? undefined :
-      create.ExternalStorage('doc', extraS3Prefix, options.innerExternalStorageCreate);
+    const externalStoreDoc = this._disableS3 ? undefined : creator('doc');
     if (!externalStoreDoc) { this._disableS3 = true; }
     const secondsBeforePush = options.secondsBeforePush;
     if (options.pushDocUpdateTimes) {
@@ -157,7 +156,7 @@ export class HostedStorageManager implements IDocStorageManager {
       this._ext = this._getChecksummedExternalStorage('doc', this._baseStore,
                                                       this._latestVersions, options);
 
-      const baseStoreMeta = create.ExternalStorage('meta', extraS3Prefix, options.innerExternalStorageCreate);
+      const baseStoreMeta = creator('meta');
       if (!baseStoreMeta) {
         throw new Error('bug: external storage should be created for "meta" if it is created for "doc"');
       }
diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts
index 0b0874ed98..a00897c8ff 100644
--- a/app/server/lib/ICreate.ts
+++ b/app/server/lib/ICreate.ts
@@ -8,6 +8,7 @@ import {IBilling} from 'app/server/lib/IBilling';
 import {INotifier} from 'app/server/lib/INotifier';
 import {ISandbox, ISandboxCreationOptions} from 'app/server/lib/ISandbox';
 import {IShell} from 'app/server/lib/IShell';
+import {createSandbox} from 'app/server/lib/NSandbox';
 
 export interface ICreate {
 
@@ -20,14 +21,7 @@ export interface ICreate {
   //  - meta. This store need not be versioned, and can be eventually consistent.
   // For test purposes an extra prefix may be supplied.  Stores with different prefixes
   // should not interfere with each other.
-  // innerCreate should be a function returning the core ExternalStorage implementation,
-  // which this method may wrap in additional layer(s) of ExternalStorage.
-  // Uses S3 by default in hosted Grist.
-  ExternalStorage(
-    purpose: 'doc' | 'meta',
-    testExtraPrefix: string,
-    innerCreate?: (bucket: string) => ExternalStorage
-  ): ExternalStorage | undefined;
+  ExternalStorage(purpose: 'doc' | 'meta', testExtraPrefix: string): ExternalStorage | undefined;
 
   ActiveDoc(docManager: DocManager, docName: string, options: ICreateActiveDocOptions): ActiveDoc;
   NSandbox(options: ISandboxCreationOptions): ISandbox;
@@ -42,3 +36,60 @@ export interface ICreateActiveDocOptions {
   docUrl?: string;
   doc?: Document;
 }
+
+export interface ICreateStorageOptions {
+  check(): Record<string, string>|undefined;
+  create(purpose: 'doc'|'meta', extraPrefix: string): ExternalStorage|undefined;
+}
+
+export function makeSimpleCreator(opts: {
+  sessionSecret?: string,
+  storage?: ICreateStorageOptions[],
+}): ICreate {
+  return {
+    Billing() {
+      return {
+        addEndpoints() { /* do nothing */ },
+        addEventHandlers() { /* do nothing */ },
+        addWebhooks() { /* do nothing */ }
+      };
+    },
+    Notifier() {
+      return {
+        get testPending() { return false; },
+        deleteUser()      { throw new Error('deleteUser unavailable'); },
+      };
+    },
+    Shell() {
+      return {
+        moveItemToTrash()  { throw new Error('moveToTrash unavailable'); },
+        showItemInFolder() { throw new Error('showItemInFolder unavailable'); }
+      };
+    },
+    ExternalStorage(purpose, extraPrefix) {
+      for (const storage of opts.storage || []) {
+        const config = storage.check();
+        if (config) { return storage.create(purpose, extraPrefix); }
+      }
+      return undefined;
+    },
+    ActiveDoc(docManager, docName, options) { return new ActiveDoc(docManager, docName, options); },
+    NSandbox(options) {
+      return createSandbox('unsandboxed', options);
+    },
+    sessionSecret() {
+      const secret = process.env.GRIST_SESSION_SECRET || opts.sessionSecret;
+      if (!secret) {
+        throw new Error('need GRIST_SESSION_SECRET');
+      }
+      return secret;
+    },
+    configurationOptions() {
+      for (const storage of opts.storage || []) {
+        const config = storage.check();
+        if (config) { return config; }
+      }
+      return {};
+    }
+  };
+}
diff --git a/buildtools/build.sh b/buildtools/build.sh
new file mode 100755
index 0000000000..b3bb832a2e
--- /dev/null
+++ b/buildtools/build.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+set -e
+
+PROJECT=""
+export GRIST_EXT=stubs
+if [[ -e ext/app ]]; then
+  PROJECT="tsconfig-ext.json"
+fi
+
+set -x
+tsc --build $PROJECT
+webpack --config buildtools/webpack.config.js --mode production
+webpack --config buildtools/webpack.check.js --mode production
+cat app/client/*.css app/client/*/*.css > static/bundle.css
diff --git a/buildtools/tsconfig-base-ext.json b/buildtools/tsconfig-base-ext.json
new file mode 100644
index 0000000000..3f42b8af52
--- /dev/null
+++ b/buildtools/tsconfig-base-ext.json
@@ -0,0 +1,12 @@
+{
+  "extends": "./tsconfig-base.json",
+  "compilerOptions": {
+    "paths": {
+      "*": [
+        "*",
+        "ext/*",
+        "stubs/*"
+      ],
+    }
+  }
+}
diff --git a/buildtools/tsconfig-base.json b/buildtools/tsconfig-base.json
index 093af4f528..7cb62e28e6 100644
--- a/buildtools/tsconfig-base.json
+++ b/buildtools/tsconfig-base.json
@@ -16,7 +16,8 @@
       "*": [
         "*",
         "grist-core/*",
-        "stubs/*"
+        "stubs/*",
+        "ext/*"
       ],
     },
     "composite": true,
diff --git a/buildtools/webpack.config.js b/buildtools/webpack.config.js
index 41786c3c63..3f5a8aa27b 100644
--- a/buildtools/webpack.config.js
+++ b/buildtools/webpack.config.js
@@ -34,6 +34,7 @@ module.exports = {
   resolve: {
     modules: [
       path.resolve('./_build'),
+      path.resolve('./_build/ext'),
       path.resolve('./_build/stubs'),
       path.resolve('./node_modules')
     ],
diff --git a/package.json b/package.json
index eedb55c9db..ebc7d997be 100644
--- a/package.json
+++ b/package.json
@@ -6,15 +6,15 @@
   "homepage": "https://github.com/gristlabs/grist-core",
   "repository": "git://github.com/gristlabs/grist-core.git",
   "scripts": {
-    "start": "tsc --build -w --preserveWatchOutput & catw app/client/*.css app/client/*/*.css -o static/bundle.css -v & webpack --config buildtools/webpack.config.js --mode development --watch --hide-modules & NODE_PATH=_build:_build/stubs nodemon --delay 1 -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js & wait",
+    "start": "sandbox/watch.sh",
     "install:python": "buildtools/prepare_python.sh",
     "install:python2": "buildtools/prepare_python2.sh",
     "install:python3": "buildtools/prepare_python3.sh",
-    "build:prod": "tsc --build && webpack --config buildtools/webpack.config.js --mode production && webpack --config buildtools/webpack.check.js --mode production && cat app/client/*.css app/client/*/*.css > static/bundle.css",
-    "start:prod": "NODE_PATH=_build:_build/stubs node _build/stubs/app/server/server.js",
-    "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true NODE_PATH=_build:_build/stubs mocha _build/test/nbrowser/*.js _build/test/server/**/*.js _build/test/gen-server/**/*.js",
-    "test:server": "GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs mocha _build/test/server/**/*.js _build/test/gen-server/**/*.js",
-    "test:smoke": "NODE_PATH=_build:_build/stubs mocha _build/test/nbrowser/Smoke.js",
+    "build:prod": "buildtools/build.sh",
+    "start:prod": "sandbox/run.sh",
+    "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/nbrowser/*.js _build/test/server/**/*.js _build/test/gen-server/**/*.js",
+    "test:server": "GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/server/**/*.js _build/test/gen-server/**/*.js",
+    "test:smoke": "NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/nbrowser/Smoke.js",
     "test:docker": "./test/test_under_docker.sh"
   },
   "keywords": [
diff --git a/sandbox/gvisor/get_checkpoint_path.sh b/sandbox/gvisor/get_checkpoint_path.sh
index 37b824c922..b09caf111b 100755
--- a/sandbox/gvisor/get_checkpoint_path.sh
+++ b/sandbox/gvisor/get_checkpoint_path.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 
 # This defines a GRIST_CHECKPOINT environment variable, where we will store
 # a sandbox checkpoint. The path is in principle arbitrary. In practice,
diff --git a/sandbox/gvisor/update_engine_checkpoint.sh b/sandbox/gvisor/update_engine_checkpoint.sh
index 4f35def540..70419c89f8 100755
--- a/sandbox/gvisor/update_engine_checkpoint.sh
+++ b/sandbox/gvisor/update_engine_checkpoint.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 
 # Create a checkpoint of a gvisor sandbox. It is best to make the
 # checkpoint in as close to the same circumstances as it will be used,
@@ -18,7 +18,7 @@ set -ex
 
 SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
 
-export NODE_PATH=_build:_build/core:_build/stubs
+export NODE_PATH=_build:_build/core:_build/stubs:_build/ext
 source $SCRIPT_DIR/get_checkpoint_path.sh
 
 if [[ -z "GRIST_CHECKPOINT" ]]; then
diff --git a/sandbox/run.sh b/sandbox/run.sh
index 74234e5546..c213d48583 100755
--- a/sandbox/run.sh
+++ b/sandbox/run.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 
 set -e
 
@@ -7,4 +7,4 @@ if [[ "$GRIST_SANDBOX_FLAVOR" = "gvisor" ]]; then
   source ./sandbox/gvisor/get_checkpoint_path.sh
 fi
 
-exec yarn run start:prod
+NODE_PATH=_build:_build/stubs:_build/ext node _build/stubs/app/server/server.js
diff --git a/sandbox/watch.sh b/sandbox/watch.sh
new file mode 100755
index 0000000000..d83302d6d3
--- /dev/null
+++ b/sandbox/watch.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+
+set -x
+
+PROJECT=""
+export GRIST_EXT=stubs
+if [[ -e ext/app ]]; then
+  PROJECT="tsconfig-ext.json"
+fi
+
+if [ ! -e _build ]; then
+  buildtools/build.sh
+fi
+
+tsc --build -w --preserveWatchOutput $PROJECT &
+catw app/client/*.css app/client/*/*.css -o static/bundle.css -v & webpack --config buildtools/webpack.config.js --mode development --watch --hide-modules &
+NODE_PATH=_build:_build/stubs:_build/ext nodemon --delay 1 -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js &
+
+wait
diff --git a/stubs/app/server/lib/create.ts b/stubs/app/server/lib/create.ts
index 62b22d39ca..d0c3288758 100644
--- a/stubs/app/server/lib/create.ts
+++ b/stubs/app/server/lib/create.ts
@@ -1,37 +1,5 @@
-import {ActiveDoc} from 'app/server/lib/ActiveDoc';
-import {ICreate} from 'app/server/lib/ICreate';
-import {createSandbox} from 'app/server/lib/NSandbox';
+import { makeSimpleCreator } from 'app/server/lib/ICreate';
 
-export const create: ICreate = {
-  Billing() {
-    return {
-      addEndpoints() { /* do nothing */ },
-      addEventHandlers() { /* do nothing */ },
-      addWebhooks() { /* do nothing */ }
-    };
-  },
-  Notifier() {
-    return {
-      get testPending() { return false; },
-      deleteUser()      { throw new Error('deleteUser unavailable'); },
-    };
-  },
-  Shell() {
-    return {
-      moveItemToTrash()  { throw new Error('moveToTrash unavailable'); },
-      showItemInFolder() { throw new Error('showItemInFolder unavailable'); }
-    };
-  },
-  ExternalStorage() { return undefined; },
-  ActiveDoc(docManager, docName, options) { return new ActiveDoc(docManager, docName, options); },
-  NSandbox(options) {
-    return createSandbox('unsandboxed', options);
-  },
-  sessionSecret() {
-    return process.env.GRIST_SESSION_SECRET ||
-      'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh';
-  },
-  configurationOptions() {
-    return {};
-  }
-};
+export const create = makeSimpleCreator({
+  sessionSecret: 'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh'
+});
diff --git a/tsconfig-ext.json b/tsconfig-ext.json
new file mode 100644
index 0000000000..814d5c361d
--- /dev/null
+++ b/tsconfig-ext.json
@@ -0,0 +1,8 @@
+{
+  "extends": "./buildtools/tsconfig-base-ext.json",
+  "files": [],
+  "include": [],
+  "references": [
+    { "path": "./ext/app" }
+  ],
+}