From dc8aab1dc0ceb5181d1c8676fa54b161113a2aed Mon Sep 17 00:00:00 2001
From: muxator <a.mux@inwind.it>
Date: Sun, 10 Mar 2019 02:11:45 +0100
Subject: [PATCH 1/7] settings.json.template: minor rewording of a comment

---
 settings.json.template | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/settings.json.template b/settings.json.template
index a89cc4247cb..336d0ebc85f 100644
--- a/settings.json.template
+++ b/settings.json.template
@@ -3,8 +3,8 @@
  *
  * Please edit settings.json, not settings.json.template
  *
- * Please note that since Etherpad 1.6.0 you can store DB credentials in a
- * separate file (credentials.json).
+ * Please note that starting from Etherpad 1.6.0 you can store DB credentials in
+ * a separate file (credentials.json).
  */
 {
   /*

From 5acc122e84fb7b6ae0f72109fd82dbcbd6b6621d Mon Sep 17 00:00:00 2001
From: muxator <a.mux@inwind.it>
Date: Sun, 10 Mar 2019 01:46:55 +0100
Subject: [PATCH 2/7] Settings.js: trivial reformatting

---
 src/node/utils/Settings.js | 87 +++++++++++++++-----------------------
 1 file changed, 35 insertions(+), 52 deletions(-)

diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js
index 508e6148d4c..91f4c67806e 100644
--- a/src/node/utils/Settings.js
+++ b/src/node/utils/Settings.js
@@ -287,29 +287,26 @@ exports.scrollWhenFocusLineIsOutOfViewport = {
 //checks if abiword is avaiable
 exports.abiwordAvailable = function()
 {
-  if(exports.abiword != null)
-  {
+  if (exports.abiword != null) {
     return os.type().indexOf("Windows") != -1 ? "withoutPDF" : "yes";
-  }
-  else
-  {
+  } else {
     return "no";
   }
 };
 
-exports.sofficeAvailable = function () {
-  if(exports.soffice != null) {
+exports.sofficeAvailable = function() {
+  if (exports.soffice != null) {
     return os.type().indexOf("Windows") != -1 ? "withoutPDF": "yes";
   } else {
     return "no";
   }
 };
 
-exports.exportAvailable = function () {
+exports.exportAvailable = function() {
   var abiword = exports.abiwordAvailable();
   var soffice = exports.sofficeAvailable();
 
-  if(abiword == "no" && soffice == "no") {
+  if (abiword == "no" && soffice == "no") {
     return "no";
   } else if ((abiword == "withoutPDF" && soffice == "no") || (abiword == "no" && soffice == "withoutPDF")) {
     return "withoutPDF";
@@ -321,8 +318,7 @@ exports.exportAvailable = function () {
 // Provide git version if available
 exports.getGitCommit = function() {
   var version = "";
-  try
-  {
+  try {
     var rootPath = path.resolve(npm.dir, '..');
     if (fs.lstatSync(rootPath + '/.git').isFile()) {
       rootPath = fs.readFileSync(rootPath + '/.git', "utf8");
@@ -334,9 +330,7 @@ exports.getGitCommit = function() {
     var refPath = rootPath + "/" + ref.substring(5, ref.indexOf("\n"));
     version = fs.readFileSync(refPath, "utf-8");
     version = version.substring(0, 7);
-  }
-  catch(e)
-  {
+  } catch(e) {
     console.warn("Can't get git version for server header\n" + e.message)
   }
   return version;
@@ -350,24 +344,24 @@ exports.getEpVersion = function() {
 exports.reloadSettings = function reloadSettings() {
   // Discover where the settings file lives
   var settingsFilename = absolutePaths.makeAbsolute(argv.settings || "settings.json");
-  
+
   // Discover if a credential file exists
   var credentialsFilename = absolutePaths.makeAbsolute(argv.credentials || "credentials.json");
 
   var settingsStr, credentialsStr;
-  try{
+  try {
     //read the settings sync
     settingsStr = fs.readFileSync(settingsFilename).toString();
     console.info(`Settings loaded from: ${settingsFilename}`);
-  } catch(e){
+  } catch(e) {
     console.warn(`No settings file found in ${settingsFilename}. Continuing using defaults!`);
   }
 
-  try{
+  try {
     //read the credentials sync
     credentialsStr = fs.readFileSync(credentialsFilename).toString();
     console.info(`Credentials file read from: ${credentialsFilename}`);
-  } catch(e){
+  } catch(e) {
     // Doesn't matter if no credentials file found..
     console.info(`No credentials file found in ${credentialsFilename}. Ignoring.`);
   }
@@ -376,68 +370,58 @@ exports.reloadSettings = function reloadSettings() {
   var settings;
   var credentials;
   try {
-    if(settingsStr) {
+    if (settingsStr) {
       settingsStr = jsonminify(settingsStr).replace(",]","]").replace(",}","}");
       settings = JSON.parse(settingsStr);
     }
-  }catch(e){
+  } catch(e) {
     console.error(`There was an error processing your settings file from ${settingsFilename}:` + e.message);
     process.exit(1);
   }
 
-  if(credentialsStr) {
+  if (credentialsStr) {
     credentialsStr = jsonminify(credentialsStr).replace(",]","]").replace(",}","}");
     credentials = JSON.parse(credentialsStr);
   }
 
   //loop trough the settings
-  for(var i in settings)
-  {
+  for (var i in settings) {
     //test if the setting start with a lowercase character
-    if(i.charAt(0).search("[a-z]") !== 0)
-    {
+    if (i.charAt(0).search("[a-z]") !== 0) {
       console.warn(`Settings should start with a lowercase character: '${i}'`);
     }
 
     //we know this setting, so we overwrite it
     //or it's a settings hash, specific to a plugin
-    if(exports[i] !== undefined || i.indexOf('ep_')==0)
-    {
+    if (exports[i] !== undefined || i.indexOf('ep_') == 0) {
       if (_.isObject(settings[i]) && !_.isArray(settings[i])) {
         exports[i] = _.defaults(settings[i], exports[i]);
       } else {
         exports[i] = settings[i];
       }
-    }
-    //this setting is unkown, output a warning and throw it away
-    else
-    {
+    } else {
+      // this setting is unknown, output a warning and throw it away
       console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`);
     }
   }
 
   //loop trough the settings
-  for(var i in credentials)
-  {
+  for (var i in credentials) {
     //test if the setting start with a lowercase character
-    if(i.charAt(0).search("[a-z]") !== 0)
-    {
+    if (i.charAt(0).search("[a-z]") !== 0) {
       console.warn(`Settings should start with a lowercase character: '${i}'`);
     }
 
     //we know this setting, so we overwrite it
     //or it's a settings hash, specific to a plugin
-    if(exports[i] !== undefined || i.indexOf('ep_')==0)
-    {
+    if (exports[i] !== undefined || i.indexOf('ep_') == 0) {
       if (_.isObject(credentials[i]) && !_.isArray(credentials[i])) {
         exports[i] = _.defaults(credentials[i], exports[i]);
       } else {
         exports[i] = credentials[i];
       }
-    }
-    //this setting is unkown, output a warning and throw it away
-    else
-    {
+    } else {
+      // this setting is unknown, output a warning and throw it away
       console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`);
     }
   }
@@ -483,14 +467,13 @@ exports.reloadSettings = function reloadSettings() {
     console.info(`Using skin "${exports.skinName}" in dir: ${skinPath}`);
   }
 
-  if(exports.abiword){
+  if (exports.abiword) {
     // Check abiword actually exists
-    if(exports.abiword != null)
-    {
+    if (exports.abiword != null) {
       fs.exists(exports.abiword, function(exists) {
         if (!exists) {
           var abiwordError = "Abiword does not exist at this path, check your settings file";
-          if(!exports.suppressErrorsInPadText){
+          if (!exports.suppressErrorsInPadText) {
             exports.defaultPadText = exports.defaultPadText + "\nError: " + abiwordError + suppressDisableMsg;
           }
           console.error(abiwordError);
@@ -500,12 +483,12 @@ exports.reloadSettings = function reloadSettings() {
     }
   }
 
-  if(exports.soffice) {
-    fs.exists(exports.soffice, function (exists) {
-      if(!exists) {
+  if (exports.soffice) {
+    fs.exists(exports.soffice, function(exists) {
+      if (!exists) {
         var sofficeError = "SOffice does not exist at this path, check your settings file";
 
-        if(!exports.suppressErrorsInPadText) {
+        if (!exports.suppressErrorsInPadText) {
           exports.defaultPadText = exports.defaultPadText + "\nError: " + sofficeError + suppressDisableMsg;
         }
         console.error(sofficeError);
@@ -528,9 +511,9 @@ exports.reloadSettings = function reloadSettings() {
     console.warn("Declaring the sessionKey in the settings.json is deprecated. This value is auto-generated now. Please remove the setting from the file.");
   }
 
-  if(exports.dbType === "dirty"){
+  if (exports.dbType === "dirty") {
     var dirtyWarning = "DirtyDB is used. This is fine for testing but not recommended for production.";
-    if(!exports.suppressErrorsInPadText){
+    if (!exports.suppressErrorsInPadText) {
       exports.defaultPadText = exports.defaultPadText + "\nWarning: " + dirtyWarning + suppressDisableMsg;
     }
 

From 68073c0e23e5085ea358b1e5bd58ec632d6dd682 Mon Sep 17 00:00:00 2001
From: muxator <a.mux@inwind.it>
Date: Sat, 9 Mar 2019 08:59:39 +0100
Subject: [PATCH 3/7] Settings.js: trivial rewording of abiword and soffice
 (libreoffice) error messages

---
 src/node/utils/Settings.js | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js
index 91f4c67806e..206413c8496 100644
--- a/src/node/utils/Settings.js
+++ b/src/node/utils/Settings.js
@@ -472,11 +472,11 @@ exports.reloadSettings = function reloadSettings() {
     if (exports.abiword != null) {
       fs.exists(exports.abiword, function(exists) {
         if (!exists) {
-          var abiwordError = "Abiword does not exist at this path, check your settings file";
+          var abiwordError = "Abiword does not exist at this path, check your settings file.";
           if (!exports.suppressErrorsInPadText) {
             exports.defaultPadText = exports.defaultPadText + "\nError: " + abiwordError + suppressDisableMsg;
           }
-          console.error(abiwordError);
+          console.error(abiwordError + ` File location: ${exports.abiword}`);
           exports.abiword = null;
         }
       });
@@ -486,12 +486,12 @@ exports.reloadSettings = function reloadSettings() {
   if (exports.soffice) {
     fs.exists(exports.soffice, function(exists) {
       if (!exists) {
-        var sofficeError = "SOffice does not exist at this path, check your settings file";
+        var sofficeError = "soffice (libreoffice) does not exist at this path, check your settings file.";
 
         if (!exports.suppressErrorsInPadText) {
           exports.defaultPadText = exports.defaultPadText + "\nError: " + sofficeError + suppressDisableMsg;
         }
-        console.error(sofficeError);
+        console.error(sofficeError + ` File location: ${exports.soffice}`);
         exports.soffice = null;
       }
     });

From 72c609f8dfcb4eb8c834fd5e681f6cf6d4cd9748 Mon Sep 17 00:00:00 2001
From: muxator <a.mux@inwind.it>
Date: Sun, 10 Mar 2019 00:26:36 +0100
Subject: [PATCH 4/7] Settings.js: exit gracefully if an invalid
 credentials.json is passed.

Before this commit, when passed a malformed credentials.json the application
crashed with a stack dump. Now we catch the error and fail in a controlled way
(like already done for settings.json).

Example of exception we no longer throw:
  MALFORMEDJSON
  ^

  SyntaxError: Unexpected token M in JSON at position 0
      at JSON.parse (<anonymous>)
      at Object.reloadSettings (<BASEDIR>/src/node/utils/Settings.js:390:24)
      at Object.<anonymous> (<BASEDIR>/src/node/utils/Settings.js:543:9)
      at Module._compile (module.js:635:30)
      at Object.Module._extensions..js (module.js:646:10)
      at Module.load (module.js:554:32)
      at tryModuleLoad (module.js:497:12)
      at Function.Module._load (module.js:489:3)
      at Module.require (module.js:579:17)
      at require (internal/module.js:11:18)
---
 src/node/utils/Settings.js | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js
index 206413c8496..98756c27f8c 100644
--- a/src/node/utils/Settings.js
+++ b/src/node/utils/Settings.js
@@ -379,9 +379,14 @@ exports.reloadSettings = function reloadSettings() {
     process.exit(1);
   }
 
-  if (credentialsStr) {
-    credentialsStr = jsonminify(credentialsStr).replace(",]","]").replace(",}","}");
-    credentials = JSON.parse(credentialsStr);
+  try {
+    if (credentialsStr) {
+      credentialsStr = jsonminify(credentialsStr).replace(",]","]").replace(",}","}");
+      credentials = JSON.parse(credentialsStr);
+    }
+  } catch(e) {
+    console.error(`There was an error processing your credentials file from ${credentialsFilename}:` + e.message);
+    process.exit(1);
   }
 
   //loop trough the settings

From f696916f39eb469571a8cbac1c485cd0a64bfe8d Mon Sep 17 00:00:00 2001
From: muxator <a.mux@inwind.it>
Date: Sat, 9 Mar 2019 10:06:51 +0100
Subject: [PATCH 5/7] Settings.js: factored out storeSettings()

Grouped copied & pasted code into a single function.
---
 src/node/utils/Settings.js | 74 ++++++++++++++++----------------------
 1 file changed, 31 insertions(+), 43 deletions(-)

diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js
index 98756c27f8c..4311bf3daf3 100644
--- a/src/node/utils/Settings.js
+++ b/src/node/utils/Settings.js
@@ -341,6 +341,35 @@ exports.getEpVersion = function() {
   return require('ep_etherpad-lite/package.json').version;
 }
 
+/**
+ * Receives a settingsObj and, if the property name is a valid configuration
+ * item, stores it in the module's exported properties via a side effect.
+ *
+ * This code refactors a previous version that copied & pasted the same code for
+ * both "settings.json" and "credentials.json".
+ */
+function storeSettings(settingsObj) {
+  for (var i in settingsObj) {
+    // test if the setting starts with a lowercase character
+    if (i.charAt(0).search("[a-z]") !== 0) {
+      console.warn(`Settings should start with a lowercase character: '${i}'`);
+    }
+
+    // we know this setting, so we overwrite it
+    // or it's a settings hash, specific to a plugin
+    if (exports[i] !== undefined || i.indexOf('ep_') == 0) {
+      if (_.isObject(settingsObj[i]) && !_.isArray(settingsObj[i])) {
+        exports[i] = _.defaults(settingsObj[i], exports[i]);
+      } else {
+        exports[i] = settingsObj[i];
+      }
+    } else {
+      // this setting is unknown, output a warning and throw it away
+      console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`);
+    }
+  }
+}
+
 exports.reloadSettings = function reloadSettings() {
   // Discover where the settings file lives
   var settingsFilename = absolutePaths.makeAbsolute(argv.settings || "settings.json");
@@ -389,47 +418,8 @@ exports.reloadSettings = function reloadSettings() {
     process.exit(1);
   }
 
-  //loop trough the settings
-  for (var i in settings) {
-    //test if the setting start with a lowercase character
-    if (i.charAt(0).search("[a-z]") !== 0) {
-      console.warn(`Settings should start with a lowercase character: '${i}'`);
-    }
-
-    //we know this setting, so we overwrite it
-    //or it's a settings hash, specific to a plugin
-    if (exports[i] !== undefined || i.indexOf('ep_') == 0) {
-      if (_.isObject(settings[i]) && !_.isArray(settings[i])) {
-        exports[i] = _.defaults(settings[i], exports[i]);
-      } else {
-        exports[i] = settings[i];
-      }
-    } else {
-      // this setting is unknown, output a warning and throw it away
-      console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`);
-    }
-  }
-
-  //loop trough the settings
-  for (var i in credentials) {
-    //test if the setting start with a lowercase character
-    if (i.charAt(0).search("[a-z]") !== 0) {
-      console.warn(`Settings should start with a lowercase character: '${i}'`);
-    }
-
-    //we know this setting, so we overwrite it
-    //or it's a settings hash, specific to a plugin
-    if (exports[i] !== undefined || i.indexOf('ep_') == 0) {
-      if (_.isObject(credentials[i]) && !_.isArray(credentials[i])) {
-        exports[i] = _.defaults(credentials[i], exports[i]);
-      } else {
-        exports[i] = credentials[i];
-      }
-    } else {
-      // this setting is unknown, output a warning and throw it away
-      console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`);
-    }
-  }
+  storeSettings(settings);
+  storeSettings(credentials);
 
   log4js.configure(exports.logconfig);//Configure the logging appenders
   log4js.setGlobalLogLevel(exports.loglevel);//set loglevel
@@ -529,5 +519,3 @@ exports.reloadSettings = function reloadSettings() {
 
 // initially load settings
 exports.reloadSettings();
-
-

From 824d61aa142cf4ce7299add865fbb53c6205ccfd Mon Sep 17 00:00:00 2001
From: muxator <a.mux@inwind.it>
Date: Sun, 10 Mar 2019 00:36:53 +0100
Subject: [PATCH 6/7] Settings.js: factored out parseSettings()

No functional changes.
---
 src/node/utils/Settings.js | 80 ++++++++++++++++++++++----------------
 1 file changed, 46 insertions(+), 34 deletions(-)

diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js
index 4311bf3daf3..412eb7cd809 100644
--- a/src/node/utils/Settings.js
+++ b/src/node/utils/Settings.js
@@ -370,53 +370,65 @@ function storeSettings(settingsObj) {
   }
 }
 
-exports.reloadSettings = function reloadSettings() {
-  // Discover where the settings file lives
-  var settingsFilename = absolutePaths.makeAbsolute(argv.settings || "settings.json");
+/**
+ * - reads the JSON configuration file settingsFilename from disk
+ * - strips the comments
+ * - returns a parsed Javascript object
+ *
+ * The isSettings variable only controls the error logging.
+ */
+function parseSettings(settingsFilename, isSettings) {
+  let settingsStr = "";
 
-  // Discover if a credential file exists
-  var credentialsFilename = absolutePaths.makeAbsolute(argv.credentials || "credentials.json");
+  let settingsType, notFoundMessage, notFoundFunction;
 
-  var settingsStr, credentialsStr;
-  try {
-    //read the settings sync
-    settingsStr = fs.readFileSync(settingsFilename).toString();
-    console.info(`Settings loaded from: ${settingsFilename}`);
-  } catch(e) {
-    console.warn(`No settings file found in ${settingsFilename}. Continuing using defaults!`);
+  if (isSettings) {
+    settingsType = "settings";
+    notFoundMessage = "Continuing using defaults!";
+    notFoundFunction = console.warn;
+  } else {
+    settingsType = "credentials";
+    notFoundMessage = "Ignoring.";
+    notFoundFunction = console.info;
   }
 
   try {
-    //read the credentials sync
-    credentialsStr = fs.readFileSync(credentialsFilename).toString();
-    console.info(`Credentials file read from: ${credentialsFilename}`);
+    //read the settings file
+    settingsStr = fs.readFileSync(settingsFilename).toString();
   } catch(e) {
-    // Doesn't matter if no credentials file found..
-    console.info(`No credentials file found in ${credentialsFilename}. Ignoring.`);
-  }
+    notFoundFunction(`No ${settingsType} file found in ${settingsFilename}. ${notFoundMessage}`);
 
-  // try to parse the settings
-  var settings;
-  var credentials;
-  try {
-    if (settingsStr) {
-      settingsStr = jsonminify(settingsStr).replace(",]","]").replace(",}","}");
-      settings = JSON.parse(settingsStr);
-    }
-  } catch(e) {
-    console.error(`There was an error processing your settings file from ${settingsFilename}:` + e.message);
-    process.exit(1);
+    // or maybe undefined!
+    return null;
   }
 
   try {
-    if (credentialsStr) {
-      credentialsStr = jsonminify(credentialsStr).replace(",]","]").replace(",}","}");
-      credentials = JSON.parse(credentialsStr);
-    }
+    settingsStr = jsonminify(settingsStr).replace(",]","]").replace(",}","}");
+
+    const settings = JSON.parse(settingsStr);
+
+    console.info(`${settingsType} loaded from: ${settingsFilename}`);
+
+    return settings;
   } catch(e) {
-    console.error(`There was an error processing your credentials file from ${credentialsFilename}:` + e.message);
+    console.error(`There was an error processing your ${settingsType} file from ${settingsFilename}: ${e.message}`);
+
     process.exit(1);
   }
+}
+
+exports.reloadSettings = function reloadSettings() {
+  // Discover where the settings file lives
+  var settingsFilename = absolutePaths.makeAbsolute(argv.settings || "settings.json");
+
+  // Discover if a credential file exists
+  var credentialsFilename = absolutePaths.makeAbsolute(argv.credentials || "credentials.json");
+
+  // try to parse the settings
+  var settings = parseSettings(settingsFilename, true);
+
+  // try to parse the credentials
+  var credentials = parseSettings(credentialsFilename, false);
 
   storeSettings(settings);
   storeSettings(credentials);

From 3b6b12e8482bb641252c2aa8fff225d61cd5225b Mon Sep 17 00:00:00 2001
From: muxator <a.mux@inwind.it>
Date: Sat, 9 Mar 2019 23:01:21 +0100
Subject: [PATCH 7/7] Settings.js: support configuration via environment
 variables.

All the configuration values can be read from environment variables using the
syntax "${ENV_VAR_NAME}".
This is useful, for example, when running in a Docker container.

EXAMPLE:
   "port":     "${PORT}"
   "minify":   "${MINIFY}"
   "skinName": "${SKIN_NAME}"

Would read the configuration values for those items from the environment
variables PORT, MINIFY and SKIN_NAME.

REMARKS:
Please note that a variable substitution always needs to be quoted.
   "port":   9001,          <-- Literal values. When not using substitution,
   "minify": false              only strings must be quoted: booleans and
   "skin":   "colibris"         numbers must not.

   "port":   ${PORT}        <-- ERROR: this is not valid json
   "minify": ${MINIFY}
   "skin":   ${SKIN_NAME}

   "port":   "${PORT}"      <-- CORRECT: if you want to use a variable
   "minify": "${MINIFY}"        substitution, put quotes around its name,
   "skin":   "${SKIN_NAME}"     even if the required value is a number or a
                                boolean.
                                Etherpad will take care of rewriting it to
                                the proper type if necessary.

Resolves #3543
---
 docker/README.md           |   2 +
 settings.json.template     |  33 +++++++++++
 src/node/utils/Settings.js | 113 ++++++++++++++++++++++++++++++++++++-
 3 files changed, 147 insertions(+), 1 deletion(-)

diff --git a/docker/README.md b/docker/README.md
index 2c5ad028c93..87d6e81eaca 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -14,6 +14,8 @@ cp ../settings.json.template settings.json
 [ further edit your settings.json as needed]
 ```
 
+**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR_NAME}"`. For details, refer to `settings.json.template`.
+
 Build the version you prefer:
 ```bash
 # builds latest development version
diff --git a/settings.json.template b/settings.json.template
index 336d0ebc85f..50a095bac65 100644
--- a/settings.json.template
+++ b/settings.json.template
@@ -5,6 +5,39 @@
  *
  * Please note that starting from Etherpad 1.6.0 you can store DB credentials in
  * a separate file (credentials.json).
+ *
+ *
+ * ENVIRONMENT VARIABLE SUBSTITUTION
+ * =================================
+ *
+ * All the configuration values can be read from environment variables using the
+ * syntax "${ENV_VAR_NAME}".
+ * This is useful, for example, when running in a Docker container.
+ *
+ * EXAMPLE:
+ *    "port":     "${PORT}"
+ *    "minify":   "${MINIFY}"
+ *    "skinName": "${SKIN_NAME}"
+ *
+ * Would read the configuration values for those items from the environment
+ * variables PORT, MINIFY and SKIN_NAME.
+ *
+ * REMARKS:
+ * Please note that a variable substitution always needs to be quoted.
+ *    "port":   9001,          <-- Literal values. When not using substitution,
+ *    "minify": false              only strings must be quoted: booleans and
+ *    "skin":   "colibris"         numbers must not.
+ *
+ *    "port":   ${PORT}        <-- ERROR: this is not valid json
+ *    "minify": ${MINIFY}
+ *    "skin":   ${SKIN_NAME}
+ *
+ *    "port":   "${PORT}"      <-- CORRECT: if you want to use a variable
+ *    "minify": "${MINIFY}"        substitution, put quotes around its name,
+ *    "skin":   "${SKIN_NAME}"     even if the required value is a number or a
+ *                                 boolean.
+ *                                 Etherpad will take care of rewriting it to
+ *                                 the proper type if necessary.
  */
 {
   /*
diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js
index 412eb7cd809..d013d422294 100644
--- a/src/node/utils/Settings.js
+++ b/src/node/utils/Settings.js
@@ -370,9 +370,118 @@ function storeSettings(settingsObj) {
   }
 }
 
+/**
+ * Takes a javascript object containing Etherpad's configuration, and returns
+ * another object, in which all the string properties whose name is of the form
+ * "${ENV_VAR}", got their value replaced with the value of the given
+ * environment variable.
+ *
+ * An environment variable's value is always a string. However, the code base
+ * makes use of the various json types. To maintain compatiblity, some
+ * heuristics is applied:
+ *
+ * - if ENV_VAR does not exist in the environment, null is returned;
+ * - if ENV_VAR's value is "true" or "false", it is converted to the js boolean
+ *   values true or false;
+ * - if ENV_VAR's value looks like a number, it is converted to a js number
+ *   (details in the code).
+ *
+ * Variable substitution is performed doing a round trip conversion to/from
+ * json, using a custom replacer parameter in JSON.stringify(), and parsing the
+ * JSON back again. This ensures that environment variable replacement is
+ * performed even on nested objects.
+ *
+ * see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter
+ */
+function lookupEnvironmentVariables(obj) {
+  const stringifiedAndReplaced = JSON.stringify(obj, (key, value) => {
+    /*
+     * the first invocation of replacer() is with an empty key. Just go on, or
+     * we would zap the entire object.
+     */
+    if (key === '') {
+      return value;
+    }
+
+    /*
+     * If we received from the configuration file a number, a boolean or
+     * something that is not a string, we can be sure that it was a literal
+     * value. No need to perform any variable substitution.
+     *
+     * The environment variable expansion syntax "${ENV_VAR}" is just a string
+     * of specific form, after all.
+     */
+    if (typeof value !== 'string') {
+      return value;
+    }
+
+    /*
+     * Let's check if the string value looks like a variable expansion (e.g.:
+     * "${ENV_VAR}")
+     */
+    const match = value.match(/^\$\{(.*)\}$/);
+
+    if (match === null) {
+      // no match: use the value literally, without any substitution
+
+      return value;
+    }
+
+    // we found the name of an environment variable. Let's read its value.
+    const envVarName = match[1];
+    const envVarValue = process.env[envVarName];
+
+    if (envVarValue === undefined) {
+      console.warn(`Configuration key ${key} tried to read its value from environment variable ${envVarName}, but no value was found. Returning null. Please check your configuration and environment settings.`);
+
+      /*
+       * We have to return null, because if we just returned undefined, the
+       * configuration item "key" would be stripped from the returned object.
+       */
+      return null;
+    }
+
+    // envVarName contained some value.
+
+    /*
+     * For numeric and boolean strings let's convert it to proper types before
+     * returning it, in order to maintain backward compatibility.
+     */
+
+    // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number
+    const isNumeric = !isNaN(envVarValue) && !isNaN(parseFloat(envVarValue) && isFinite(envVarValue));
+
+    if (isNumeric) {
+      console.debug(`Configuration key "${key}" will be read from environment variable ${envVarName}. Detected numeric string, that will be coerced to a number`);
+
+      return +envVarValue;
+    }
+
+    // the boolean literal case is easy.
+    if (envVarValue === "true" || envVarValue === "false") {
+      console.debug(`Configuration key "${key}" will be read from environment variable ${envVarName}. Detected boolean string, that will be coerced to a boolean`);
+
+      return (envVarValue === "true");
+    }
+
+    /*
+     * The only remaining case is that envVarValue is a string with no special
+     * meaning, and we just return it as-is.
+     */
+    console.debug(`Configuration key "${key}" will be read from environment variable ${envVarName}`);
+
+    return envVarValue;
+  });
+
+  const newSettings = JSON.parse(stringifiedAndReplaced);
+
+  return newSettings;
+}
+
 /**
  * - reads the JSON configuration file settingsFilename from disk
  * - strips the comments
+ * - replaces environment variables calling lookupEnvironmentVariables()
  * - returns a parsed Javascript object
  *
  * The isSettings variable only controls the error logging.
@@ -409,7 +518,9 @@ function parseSettings(settingsFilename, isSettings) {
 
     console.info(`${settingsType} loaded from: ${settingsFilename}`);
 
-    return settings;
+    const replacedSettings = lookupEnvironmentVariables(settings);
+
+    return replacedSettings;
   } catch(e) {
     console.error(`There was an error processing your ${settingsType} file from ${settingsFilename}: ${e.message}`);