diff --git a/codeception.yml b/codeception.yml
index 711759c1a..a69896d7f 100644
--- a/codeception.yml
+++ b/codeception.yml
@@ -19,16 +19,9 @@ extensions:
         - Codeception\Extension\RunFailed
 modules:
     enabled:
-        - \Helper\DeployerFactory:
-            secrets:
-                cpanel:
-                    enabled:  '%cpanel.enabled%'
-                    hostname: '%cpanel.hostname%'
-                    username: '%cpanel.username%'
-                    password: '%cpanel.password%'
         - \Helper\DelayedDb:
-            dsn: 'mysql:host=%manual.db.host%;port=%manual.db.port%;dbname=%manual.db.dbname%'
-            user: '%manual.db.user%'
-            password: '%manual.db.password%'
-            populate: '%db_dump.enabled%'
-            dump: '%db_dump.path%'
+            dsn: 'mysql:host=%db.host%;port=%db.port%;dbname=%db.dbname%'
+            user: '%db.user%'
+            password: '%db.password%'
+            populate: '%db.populate%'
+            dump: '%db.dump_path%'
diff --git a/config.sample.yml b/config.sample.yml
index eb89499f8..88d01ae43 100644
--- a/config.sample.yml
+++ b/config.sample.yml
@@ -4,24 +4,24 @@
 # Absolute path begins with "/"; relative path does not begin with "/"
 app_path: 'e107/'
 
-# Configure this section to customize the database populator
-db_dump:
+# Which deployer to use for acceptance tests. Options:
+#
+# 'none'
+#   Dummy deployer that does nothing. Tests that depend on a deployer will fail.
+# 'local'
+#   Use this if the acceptance test web server directly serves files from "app_path".
+#   Configure the "url" and "db" sections.
+# 'sftp'
+#   Deploys the files in "app_path" to an SFTP account.
+#   Configure the "url", "db", and "fs" sections.
+# 'cpanel'
+#   Deploys the files in "app_path" to a cPanel account's main domain.
+#   Configure the "cpanel" section.
+deployer: 'local'
 
-  # If set to true, the populator will populate the database with the dump specified in the "path" key
-  # If set to false, the test database needs to be set up separately
-  # Affects all modes of deployment
-  enabled: true
-
-  # Path (absolute or relative) to the database dump of a testable installation of the app
-  # Absolute path begins with "/"; relative path does not begin with "/"
-  path: 'tests/_data/e107_v2.1.8.sample.sql'
-
-# Configure this section for automated test deployments to cPanel
+# Configure this section for fully automated test deployments to cPanel
 cpanel:
 
-  # If set to true, this section takes precedence over the "manual" section.
-  enabled: false
-
   # cPanel domain without the port number
   hostname: ''
 
@@ -32,25 +32,53 @@ cpanel:
   password: ''
 
 
-# Configure this section for manual test deployments
-manual:
+# URL (with trailing slash) at which the app can be reached for acceptance tests
+url: 'http://set-this-to-your-acceptance-test-url.local/'
 
-  # URL to the app that you deployed manually; needed for acceptance tests
-  url: 'http://set-this-if-running-acceptance-tests-manually.local'
+# Only MySQL/MariaDB is supported
+db:
 
-  # Only MySQL/MariaDB is supported
-  db:
-    # Hostname or IP address; use 'localhost' for a local server
-    host: 'set-this-if-running-tests-manually.local'
+  # Hostname or IP address; use 'localhost' for a local server
+  host: 'set-this-to-your-test-database-hostname.local'
 
-    # Port number of the server
-    port: '3306'
+  # Port number of the server
+  port: '3306'
 
-    # Database name; must exist already
-    dbname: 'e107'
+  # Database name; must exist already
+  dbname: 'e107'
 
-    # Username; must exist already
-    user: 'root'
+  # Username; must exist already
+  user: 'root'
 
-    # Password; set to blank string for no password
-    password: ''
+  # Password; set to blank string for no password
+  password: ''
+
+  # If set to true, the database populator will populate the database with the dump specified in the "dump_path" key
+  # If set to false, the test database needs to be set up separately
+  # Affects all tests and modes of deployment
+  populate: true
+
+  # Path (absolute or relative) to the database dump of a testable installation of the app
+  # Absolute path begins with "/"; relative path does not begin with "/"
+  dump_path: 'tests/_data/e107_v2.1.8.sample.sql'
+
+# Configure this section for deployers that need file upload configuration
+fs:
+
+  # Hostname or IP address to the remote destination
+  host: ''
+
+  # Port number of the file transfer server
+  port: '22'
+
+  # Username used for the file transfer
+  user: ''
+
+  # Path to the private key of the user. Takes precedence over "fs.password"
+  privkey_path: ''
+
+  # Password of the file transfer user. Ignored if "fs.privkey_path" is specified
+  password: ''
+
+  # Absolute path to where the remote web server serves "url"
+  path: ''
\ No newline at end of file
diff --git a/e107 b/e107
index e2460e0b3..73fbe980a 160000
--- a/e107
+++ b/e107
@@ -1 +1 @@
-Subproject commit e2460e0b3aa2fe562c2484b5742365a72fa334c1
+Subproject commit 73fbe980a432c4520ea99b21f11a16a5fd88110b
diff --git a/lib/config.php b/lib/config.php
index d5f8d58f5..3933179e3 100644
--- a/lib/config.php
+++ b/lib/config.php
@@ -12,7 +12,7 @@ foreach ([
 {
 	$absolute_config_path = codecept_root_dir() . '/' . $config_filename;
 	if (file_exists($absolute_config_path))
-		$params = array_merge($params, Yaml::parse(file_get_contents($absolute_config_path)));
+		$params = array_replace_recursive($params, Yaml::parse(file_get_contents($absolute_config_path)));
 }
 
 return $params;
diff --git a/lib/deployers/Deployer.php b/lib/deployers/Deployer.php
index 861ad3ba8..a376e395d 100644
--- a/lib/deployers/Deployer.php
+++ b/lib/deployers/Deployer.php
@@ -5,6 +5,23 @@ abstract class Deployer
 	abstract public function start();
 	abstract public function stop();
 
+	protected $params;
+
+	public function __construct($params = [])
+	{
+		$this->params = $params;
+	}
+
+	protected static function println($text = '')
+	{
+		codecept_debug($text);
+
+		//echo("${text}\n");
+
+		//$prefix = debug_backtrace()[1]['function'];
+		//echo("[\033[1m${prefix}\033[0m] ${text}\n");
+	}
+
 	protected $components = array();
 
 	/**
@@ -15,6 +32,12 @@ abstract class Deployer
 		$this->components = $components;
 	}
 
+	public function unlinkAppFile($relative_path)
+	{
+		throw new \PHPUnit\Framework\SkippedTestError("Test wants \"$relative_path\" to be deleted from the app, ".
+		"but the configured deployer ".get_class($this)." is not capable of doing that.");
+	}
+
 	/**
 	 * Methods not implemented
 	 *
@@ -24,6 +47,6 @@ abstract class Deployer
 	 */
 	public function __call($method_name, $arguments)
 	{
-		return null;
+		throw new BadMethodCallException(get_class($this)."::$method_name is not implemented");
 	}
 }
\ No newline at end of file
diff --git a/lib/deployers/DeployerFactory.php b/lib/deployers/DeployerFactory.php
new file mode 100644
index 000000000..0e8afa157
--- /dev/null
+++ b/lib/deployers/DeployerFactory.php
@@ -0,0 +1,42 @@
+<?php
+spl_autoload_register(function($class_name) {
+	$candidate_path = __DIR__ . "/$class_name.php";
+	if (file_exists($candidate_path))
+	{
+		include_once($candidate_path);
+	}
+});
+#include_once("$deployers_path/Deployer.php");
+#foreach (glob("$deployers_path/*.php") as $path)
+#{
+#	include_once($path);
+#}
+
+// here you can define custom actions
+// all public methods declared in helper class will be available in $I
+
+class DeployerFactory
+{
+	/**
+	 * @return \Deployer
+	 */
+	public static function create()
+	{
+		$params = unserialize(PARAMS_SERIALIZED);
+
+		$deployer = new NoopDeployer();
+		switch ($params['deployer'])
+		{
+			case "local":
+				$deployer = new LocalDeployer($params);
+				break;
+			case "sftp":
+				$deployer = new SFTPDeployer($params);
+				break;
+			case "cpanel":
+				$deployer = new cPanelDeployer($params);
+				break;
+		}
+		return $deployer;
+	}
+}
diff --git a/lib/deployers/LocalDeployer.php b/lib/deployers/LocalDeployer.php
new file mode 100644
index 000000000..c479ab446
--- /dev/null
+++ b/lib/deployers/LocalDeployer.php
@@ -0,0 +1,18 @@
+<?php
+
+class LocalDeployer extends NoopDeployer
+{
+	public function unlinkAppFile($relative_path)
+	{
+		self::println("Deleting file \"$relative_path\" from deployed test location…");
+		if (file_exists(APP_PATH."/$relative_path"))
+		{
+			unlink(APP_PATH."/$relative_path");
+			self::println("Deleted file \"$relative_path\" from deployed test location");
+		}
+		else
+		{
+			self::println("No such file to delete: \"$relative_path\"");
+		}
+	}
+}
\ No newline at end of file
diff --git a/lib/deployers/DummyDeployer.php b/lib/deployers/NoopDeployer.php
similarity index 76%
rename from lib/deployers/DummyDeployer.php
rename to lib/deployers/NoopDeployer.php
index 12fcdc74d..5d9370559 100644
--- a/lib/deployers/DummyDeployer.php
+++ b/lib/deployers/NoopDeployer.php
@@ -1,6 +1,6 @@
 <?php
 
-class DummyDeployer extends Deployer
+class NoopDeployer extends Deployer
 {
 
 	public function start()
diff --git a/lib/deployers/SFTPDeployer.php b/lib/deployers/SFTPDeployer.php
new file mode 100644
index 000000000..c836ded5b
--- /dev/null
+++ b/lib/deployers/SFTPDeployer.php
@@ -0,0 +1,107 @@
+<?php
+
+class SFTPDeployer extends Deployer
+{
+	public function start()
+	{
+		self::println();
+		self::println("=== SFTP Deployer – Bring Up ===");
+		if (in_array('fs', $this->components))
+		{
+			$this->start_fs();
+		}
+	}
+
+	private function getFsParams()
+	{
+		return $this->params['fs'];
+	}
+
+	private function generateSshpassPrefix()
+	{
+		if (empty($this->getFsParam('privkey_path')) &&
+			!empty($this->getFsParam('password')))
+		{
+			return 'sshpass -p '.escapeshellarg($this->getFsParam('password')).' ';
+		}
+		return '';
+	}
+
+	private function getFsParam($key)
+	{
+		return $this->getFsParams()[$key];
+	}
+
+	private function generateRsyncRemoteShell()
+	{
+		$prefix = 'ssh -p '.escapeshellarg($this->getFsParam('port'));
+		if (!empty($this->getFsParam('privkey_path')))
+			return $prefix.' -i ' . escapeshellarg($this->getFsParam('privkey_path'));
+		else
+			return $prefix;
+	}
+
+	private static function runCommand($command, &$stdout = null, &$stderr = null)
+	{
+		$descriptorSpec = [
+			1 => ['pipe', 'w'],
+			2 => ['pipe', 'w'],
+		];
+		$pipes = [];
+		self::println("Running this command…:");
+		self::println($command);
+		$resource = proc_open($command, $descriptorSpec, $pipes, APP_PATH);
+		$stdout = stream_get_contents($pipes[1]);
+		$stderr = stream_get_contents($pipes[2]);
+		self::println("---------- stdout ----------");
+		self::println(trim($stdout));
+		self::println("---------- stderr ----------");
+		self::println(trim($stderr));
+		self::println("----------------------------");
+		foreach ($pipes as $pipe)
+		{
+			fclose($pipe);
+		}
+		return proc_close($resource);
+	}
+
+	public function stop()
+	{
+		self::println("=== SFTP Deployer – Tear Down ===");
+	}
+
+	public function unlinkAppFile($relative_path)
+	{
+		self::println("Deleting file \"$relative_path\" from deployed test location…");
+		$fs_params = $this->getFsParams();
+		$command = $this->generateSshpassPrefix().
+			$this->generateRsyncRemoteShell().
+			" ".escapeshellarg("{$fs_params['user']}@{$fs_params['host']}").
+			" ".escapeshellarg("rm -v " . escapeshellarg(rtrim($fs_params['path'], '/')."/$relative_path"));
+		$retcode = self::runCommand($command);
+		if ($retcode === 0)
+		{
+			self::println("Deleted file \"$relative_path\" from deployed test location");
+		}
+		else
+		{
+			self::println("No such file to delete: \"$relative_path\"");
+		}
+	}
+
+	private function start_fs()
+	{
+		$fs_params = $this->getFsParams();
+		$fs_params['path'] = rtrim($fs_params['path'], '/') . '/';
+		$command = $this->generateSshpassPrefix() .
+			'rsync -e ' .
+			escapeshellarg($this->generateRsyncRemoteShell()) .
+			' --delete -avzHXShs ' .
+			escapeshellarg(rtrim(APP_PATH, '/') . '/') . ' ' .
+			escapeshellarg("{$fs_params['user']}@{$fs_params['host']}:{$fs_params['path']}");
+		$retcode = self::runCommand($command);
+		if ($retcode !== 0) {
+			throw new Exception("SFTP deployment failed. Run with --debug to see stdout and stderr.");
+		}
+	}
+}
\ No newline at end of file
diff --git a/lib/deployers/cPanelDeployer.php b/lib/deployers/cPanelDeployer.php
index 89a566760..d3f1172d1 100644
--- a/lib/deployers/cPanelDeployer.php
+++ b/lib/deployers/cPanelDeployer.php
@@ -17,9 +17,10 @@ class cPanelDeployer extends Deployer
 	protected $domain;
 	private $skip_mysql_remote_hosts = false;
 
-	function __construct($credentials)
+	function __construct($params = [])
 	{
-		$this->credentials = $credentials;
+		parent::__construct($params);
+		$this->credentials = $params['cpanel'];
 	}
 
 	public function start()
@@ -31,8 +32,7 @@ class cPanelDeployer extends Deployer
 			!$creds['username'] ||
 			!$creds['password'])
 		{
-			self::println("Cannot deploy cPanel environment because credentials are missing. Falling back to manual mode…");
-			return false;
+			throw new Exception("Cannot deploy cPanel environment because credentials are missing.");
 		}
 
 		$this->prepare();
@@ -42,8 +42,7 @@ class cPanelDeployer extends Deployer
 			$method = "prepare_${component}";
 			if (!method_exists($this, $method))
 			{
-				self::println("Unsupported component \"${component}\" requested. Falling back to manual mode…");
-				return false;
+				throw new Exception("Unsupported component \"${component}\" requested.");
 			}
 		}
 		foreach ($this->components as $component)
@@ -51,18 +50,6 @@ class cPanelDeployer extends Deployer
 			$method = "prepare_${component}";
 			$this->$method();
 		}
-
-		return true;
-	}
-
-	private static function println($text = '')
-	{
-		codecept_debug($text);
-
-		//echo("${text}\n");
-
-		//$prefix = debug_backtrace()[1]['function'];
-		//echo("[\033[1m${prefix}\033[0m] ${text}\n");
 	}
 
 	private function prepare()
@@ -288,6 +275,13 @@ class cPanelDeployer extends Deployer
 		return "http://".$this->domain."/".$this->run_id."/";
 	}
 
+	public function unlinkAppFile($relative_path)
+	{
+		self::println("Deleting file \"$relative_path\" from deployed test location…");
+		$this->cPanel->api2->Fileman->fileop(['op' => 'unlink',
+			'sourcefiles' => self::TARGET_RELPATH.$this->run_id."/".$relative_path]);
+	}
+
 	private function prepare_db()
 	{
 		$cPanel = $this->cPanel;
@@ -373,11 +367,4 @@ class cPanelDeployer extends Deployer
 
 		return $tmp_file;
 	}
-
-	public function unlinkAppFile($relative_path)
-	{
-		self::println("Deleting file \"$relative_path\" from deployed test location…");
-		$this->cPanel->api2->Fileman->fileop(['op' => 'unlink',
-			'sourcefiles' => self::TARGET_RELPATH.$this->run_id."/".$relative_path]);
-	}
 }
diff --git a/tests/_bootstrap.php b/tests/_bootstrap.php
index 2950c7b79..b635f8686 100644
--- a/tests/_bootstrap.php
+++ b/tests/_bootstrap.php
@@ -1,6 +1,8 @@
 <?php
 
-$params = include(codecept_root_dir()."/lib/config.php");
+define('PARAMS_GENERATOR', realpath(codecept_root_dir()."/lib/config.php"));
+
+$params = include(PARAMS_GENERATOR);
 
 $app_path = $params['app_path'] ?: codecept_root_dir()."/e107";
 
@@ -9,3 +11,4 @@ if (substr($app_path, 0, 1) !== '/')
 	$app_path = codecept_root_dir() . "/${app_path}";
 
 define('APP_PATH', realpath($app_path));
+define('PARAMS_SERIALIZED', serialize($params));
\ No newline at end of file
diff --git a/tests/_support/Helper/Acceptance.php b/tests/_support/Helper/Acceptance.php
index 087998a35..29c179fab 100644
--- a/tests/_support/Helper/Acceptance.php
+++ b/tests/_support/Helper/Acceptance.php
@@ -16,13 +16,6 @@ class Acceptance extends E107Base
 
 	public function unlinkE107ConfigFromTestEnvironment()
 	{
-		// cPanel Environment
 		$this->deployer->unlinkAppFile("e107_config.php");
-
-		// Local Environment
-		if (file_exists(APP_PATH."/e107_config.php"))
-		{
-			unlink(APP_PATH."/e107_config.php");
-		}
 	}
 }
diff --git a/tests/_support/Helper/Base.php b/tests/_support/Helper/Base.php
index b3bd13d24..53aaf5369 100644
--- a/tests/_support/Helper/Base.php
+++ b/tests/_support/Helper/Base.php
@@ -1,5 +1,6 @@
 <?php
 namespace Helper;
+include_once(codecept_root_dir() . "lib/deployers/DeployerFactory.php");
 
 // here you can define custom actions
 // all public methods declared in helper class will be available in $I
@@ -9,11 +10,9 @@ abstract class Base extends \Codeception\Module
 	protected $deployer;
 	protected $deployer_components = ['db', 'fs'];
 
-	protected $db;
-
 	public function getDbModule()
 	{
-		return $this->db ?: $this->db = $this->getModule('\Helper\DelayedDb');
+		return $this->getModule('\Helper\DelayedDb');
 	}
 
 	public function getBrowserModule()
@@ -23,7 +22,7 @@ abstract class Base extends \Codeception\Module
 
 	public function _beforeSuite($settings = array())
 	{
-		$this->deployer = $this->getModule('\Helper\DeployerFactory')->create();
+		$this->deployer = \DeployerFactory::create();
 		$this->deployer->setComponents($this->deployer_components);
 
 		$this->deployer->start();
@@ -31,8 +30,10 @@ abstract class Base extends \Codeception\Module
 
 		foreach ($this->getModules() as $module)
 		{
-			if (get_class($module) !== get_class($this))
+			if (!$module instanceof $this)
+			{
 				$module->_beforeSuite();
+			}
 		}
 	}
 
diff --git a/tests/_support/Helper/DeployerFactory.php b/tests/_support/Helper/DeployerFactory.php
deleted file mode 100644
index 00da9a411..000000000
--- a/tests/_support/Helper/DeployerFactory.php
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-namespace Helper;
-$deployers_path = __DIR__ . "/../../../lib/deployers";
-include_once("{$deployers_path}/Deployer.php");
-foreach (glob("{$deployers_path}/*.php") as $path)
-{
-	include_once($path);
-}
-
-// here you can define custom actions
-// all public methods declared in helper class will be available in $I
-
-class DeployerFactory extends \Codeception\Module
-{
-	/**
-	 * @return \Deployer
-	 */
-	public function create()
-	{
-		return $this->createFromSecrets($this->config['secrets']);
-	}
-
-	/**
-	 * @param $secrets
-	 * @return \Deployer
-	 */
-	public function createFromSecrets($secrets)
-	{
-		$deployer = new \DummyDeployer();
-		if ($secrets['cpanel']['enabled'] === '1')
-		{
-			$deployer = new \cPanelDeployer($secrets['cpanel']);
-		}
-		return $deployer;
-	}
-
-}
diff --git a/tests/_support/Helper/E107Base.php b/tests/_support/Helper/E107Base.php
index cf15b301f..7eac93b16 100644
--- a/tests/_support/Helper/E107Base.php
+++ b/tests/_support/Helper/E107Base.php
@@ -55,8 +55,8 @@ abstract class E107Base extends Base
 	{
 		$descriptorspec = [
 			1 => ['pipe', 'w'],
-			  2 => ['pipe', 'w'],
-			  ];
+			2 => ['pipe', 'w'],
+		];
 		$pipes = [];
 		$resource = proc_open('git clean -fdx', $descriptorspec, $pipes, APP_PATH);
 		//$stdout = stream_get_contents($pipes[1]);
diff --git a/tests/acceptance.suite.yml b/tests/acceptance.suite.yml
index 5633ef875..8bc2afcc5 100644
--- a/tests/acceptance.suite.yml
+++ b/tests/acceptance.suite.yml
@@ -10,5 +10,5 @@ coverage:
 modules:
     enabled:
         - PhpBrowser:
-            url: '%manual.url%'
+            url: '%url%'
         - \Helper\Acceptance: