diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 7ab2f9621460a1fc2afb6a8e60a2ba869cabdac2..9e1c79cd7aa1730a8b03b5865c15cfaa10c67ac3 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,4 +1,4 @@
-# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.231.3/containers/ubuntu/.devcontainer/base.Dockerfile
+# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.231.6/containers/ubuntu/.devcontainer/base.Dockerfile
 
 # [Choice] Ubuntu version (use hirsuite or bionic on local arm64/Apple Silicon): hirsute, focal, bionic
 ARG VARIANT="hirsute"
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 6a078e871dde3007872950da06b453d59e7f20af..816edebda4e998a108367b277209f84d444c9de1 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -1,5 +1,5 @@
 // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
-// https://github.com/microsoft/vscode-dev-containers/tree/v0.231.3/containers/ubuntu
+// https://github.com/microsoft/vscode-dev-containers/tree/v0.231.6/containers/ubuntu
 {
 	"name": "Ubuntu",
 	"build": {
@@ -25,6 +25,8 @@
 	// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
 	"remoteUser": "vscode",
 	"features": {
-		"python": "latest"
+		"docker-from-docker": "20.10",
+		"git": "latest",
+		"python": "3.10"
 	}
 }
diff --git a/alice-ci/setup.cfg b/alice-ci/setup.cfg
index 3fb1ffb8ee99f183fdd06d03c21c0732b63aa5ed..8c1f9c8114e3d7738e9ce5a4ea20f83f117fc5b4 100644
--- a/alice-ci/setup.cfg
+++ b/alice-ci/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = alice-ci
-version = 0.0.9
+version = 0.0.10
 author = Daniel Gyulai
 description = Alice CI framework
 long_description = file: README.md
@@ -16,15 +16,14 @@ classifiers =
 [options]
 package_dir =
     = src
-packages = alice
+packages = 
+    alice
+    alice.runners
 python_requires = >=3.6
 install_requires =
-    PyYAML==6.0
-    virtualenv==20.14.0
+    PyYAML
+    docker
 
 [options.entry_points]
 console_scripts =
     alice = alice.cli:main
-
-[options.packages.find]
-where = src
\ No newline at end of file
diff --git a/alice-ci/src/alice/__init__.py b/alice-ci/src/alice/__init__.py
index 3c513feb535361c9875f13493fdc86b12eb89c45..9a0f501b5896e6bea7aecd4ef09b71c80d833716 100644
--- a/alice-ci/src/alice/__init__.py
+++ b/alice-ci/src/alice/__init__.py
@@ -1,10 +1,9 @@
 # flake8: noqa F401
-from alice.configparser import ConfigParser
-from alice.exceptions import NonZeroRetcode
-from alice.runnerfactory import Factory
-from alice.runners.pythonrunner import PythonRunner
-from alice.exceptions import NonZeroRetcode
-from alice.exceptions import RunnerError
-from alice.exceptions import ConfigException
+from .configparser import ConfigParser
+from .exceptions import NonZeroRetcode
+from .runnerfactory import Factory
+from .exceptions import NonZeroRetcode
+from .exceptions import RunnerError
+from .exceptions import ConfigException
 
 name = "alice"
\ No newline at end of file
diff --git a/alice-ci/src/alice/__main__.py b/alice-ci/src/alice/__main__.py
index a4d3a5474305e2d21672ff35ca5d99413c5fbb6d..d5cb563b7975acc9e7ca09afe24bab55abb9e758 100644
--- a/alice-ci/src/alice/__main__.py
+++ b/alice-ci/src/alice/__main__.py
@@ -1,3 +1,4 @@
-from alice.cli import main
+import alice
 
-main()
+if __name__ == '__main__':
+    alice.cli.main()
diff --git a/alice-ci/src/alice/cli.py b/alice-ci/src/alice/cli.py
index bfe3efbcd49989de4730d6833ff218b2b865f33a..563005542da8aa7092851b94aacb76cc7225ab2b 100644
--- a/alice-ci/src/alice/cli.py
+++ b/alice-ci/src/alice/cli.py
@@ -1,8 +1,9 @@
+import logging
 import os
 import argparse
 
-from alice.configparser import ConfigParser
-from alice.exceptions import ConfigException, NonZeroRetcode, RunnerError
+from .configparser import ConfigParser
+from .exceptions import ConfigException, NonZeroRetcode, RunnerError
 
 
 def gen_env(param_list):
@@ -20,9 +21,8 @@ def parse_jobs(args):
     try:
         if len(args.env) > 0:
             envs = gen_env(args.env)
-            if args.verbose:
-                print(f"[Alice] Env vars from CLI: {envs}")
-        jobParser = ConfigParser(args.input, gen_env(args.env), args.verbose)
+            logging.debug(f"[Alice] Env vars from CLI: {envs}")
+        jobParser = ConfigParser(args.input, gen_env(args.env))
 
         for step in args.steps:
             jobParser.execute(step)
@@ -42,8 +42,12 @@ def main():
     parser.add_argument("-i", "--input", default="alice-ci.yaml")
     parser.add_argument("-e", "--env", nargs='*', default=[])
     parser.add_argument("-a", "--addrunner", nargs='*', default=[])
-    parser.add_argument("-v", "--verbose", action='store_true')
+    parser.add_argument('--verbose', '-v', action='count', default=0)
     args = parser.parse_args()
+
+    loglevel = 30 - ((10 * args.verbose) if args.verbose > 0 else 0)
+    logging.basicConfig(level=loglevel, format='%(message)s')
+
     if not os.path.isfile(args.input):
         print(f"No such file: {args.input}")
         exit(1)
diff --git a/alice-ci/src/alice/config.py b/alice-ci/src/alice/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..d0d473b1a666077106f55f042f9921c1278341fd
--- /dev/null
+++ b/alice-ci/src/alice/config.py
@@ -0,0 +1,38 @@
+import logging
+import os
+
+from .exceptions import ConfigException
+
+
+class ConfigHolder:
+    __instance = None
+    file_name = ".alice"
+
+    @staticmethod
+    def getInstance():
+        """ Static access method. """
+        if ConfigHolder.__instance is None:
+            ConfigHolder()
+        return ConfigHolder.__instance
+
+    def __init__(self):
+        """ Virtually private constructor. """
+        if ConfigHolder.__instance is not None:
+            raise Exception("This class is a singleton!")
+        else:
+            ConfigHolder.__instance = self
+            config = os.path.abspath(os.path.join(os.getcwd(), self.file_name))
+            self.vars = {}
+            if os.path.isfile(config):
+                with open(config) as f:
+                    for line in f:
+                        items = line.split("=")
+                        if len(items) > 1:
+                            self.vars[items[0]] = line.replace(f"{items[0]}=", "")
+            logging.debug(f"Loaded from {self.file_name}: {self.vars}")
+
+    def get(self, key):
+        try:
+            self.vars[key]
+        except KeyError:
+            raise ConfigException(f"{key} not defined in .conf!")
diff --git a/alice-ci/src/alice/configparser.py b/alice-ci/src/alice/configparser.py
index bd4f69809bdc56047b9809aae3a83ada2c5b5bbb..d65b092619aabfa02d63886b812d66aee903805f 100644
--- a/alice-ci/src/alice/configparser.py
+++ b/alice-ci/src/alice/configparser.py
@@ -1,17 +1,17 @@
+import logging
 from os import getcwd, path, environ
 import subprocess
 import yaml
 
-from alice.exceptions import ConfigException
-from alice.runnerfactory import Factory
+from .exceptions import ConfigException
+from .runnerfactory import Factory
 
 
 class ConfigParser:
-    def __init__(self, file_path, cli_env_vars, verbose=False) -> None:
-        self.verbose = verbose
+    def __init__(self, file_path, cli_env_vars) -> None:
         with open(file_path) as f:
             self.config = yaml.safe_load(f)
-        self.factory = Factory(verbose, self.__gen_globals(cli_env_vars), self.config.get("runners", {}))
+        self.factory = Factory(self.__gen_globals(cli_env_vars), self.config.get("runners", {}))
         self.jobs = self.__get_jobs()
         self.pipelines = self.config.get("pipelines", {})
 
@@ -31,8 +31,7 @@ class ConfigParser:
                 if "workdir" in self.config["runners"]["global"]:
                     globals["workdir"] = self.config["runners"]["global"]["workdir"]
 
-        if (self.verbose):
-            print(f"[Alice] Configured globals: {globals}")
+        logging.debug(f"[Alice] Configured globals: {globals}")
         return globals
 
     def __get_jobs(self):
@@ -44,8 +43,7 @@ class ConfigParser:
                     raise ConfigException(f"Job with name {name} already exists!")
 
                 jobs[name] = job_spec
-            if (self.verbose):
-                print(f"[Alice] Parsed jobs: {', '.join(jobs.keys())}")
+            logging.info(f"[Alice] Parsed jobs: {', '.join(jobs.keys())}")
             return jobs
         else:
             raise ConfigException("No jobs defined in config")
@@ -65,9 +63,8 @@ class ConfigParser:
                     for _path in paths:
                         spec_path = path.abspath(_path)
                         if change_path.startswith(spec_path):
-                            if self.verbose:
-                                print(f"[Alice] Modified file: {change_path}")
-                                print(f"[Alice] Path match: {_path}")
+                            logging.info(f"[Alice] Modified file: {change_path}")
+                            logging.info(f"[Alice] Path match: {_path}")
                             return True
         except KeyError:
             raise ConfigException(f"Invalid 'changes' config: {changes}")
@@ -77,16 +74,16 @@ class ConfigParser:
         if task_name in self.jobs:
             self.execute_job(task_name)
         elif task_name in self.pipelines:
-            print(f"[Alice][Pipeline] {task_name}: Start")
             self.execute_pipeline(task_name)
-            print(f"[Alice][Pipeline] {task_name}: Success")
         else:
             raise ConfigException(f"No such job or pipeline: {task_name}")
 
     def execute_pipeline(self, pipeline_name):
         if pipeline_name in self.pipelines:
+            print(f"[Alice][Pipeline] {pipeline_name}: Start")
             for job in self.pipelines[pipeline_name]:
                 self.execute_job(job)
+            print(f"[Alice][Pipeline] {pipeline_name}: Success")
 
     def execute_job(self, job_name):
         if job_name in self.jobs:
diff --git a/alice-ci/src/alice/runnerfactory.py b/alice-ci/src/alice/runnerfactory.py
index 618248c0f36f3a037120c822a4a826cc815e931d..424d213decd945c9fc594daeddfbe5040fdc0508 100644
--- a/alice-ci/src/alice/runnerfactory.py
+++ b/alice-ci/src/alice/runnerfactory.py
@@ -1,13 +1,14 @@
+import logging
 from os.path import join, abspath
 
-from alice.runners.pythonrunner import PythonRunner
-from alice.runners.pypirunner import PyPiRunner
-from alice.exceptions import ConfigException
+from .runners.pythonrunner import PythonRunner
+from .runners.pypirunner import PyPiRunner
+from .runners.dockerrunner import DockerRunner
+from .exceptions import ConfigException
 
 
 class Factory():
-    def __init__(self, verbose, globals, runner_configs) -> None:
-        self.verbose = verbose
+    def __init__(self, globals, runner_configs) -> None:
         self.globals = globals
         self.runner_configs = {}
         self.runnertypes = {}
@@ -21,16 +22,15 @@ class Factory():
         # module = __import__("module_file")
         # my_class = getattr(module, "class_name")
         self.runnertypes = {"python": PythonRunner,
-                            "pypi": PyPiRunner}
+                            "pypi": PyPiRunner,
+                            "docker": DockerRunner}
 
-        if (self.verbose):
-            print(f"[Alice] Available runners: {'|'.join(self.runnertypes.keys())}")
+        logging.info(f"[Alice] Available runners: {'|'.join(self.runnertypes.keys())}")
 
     def __gen_runner_configs(self, config):
         for runnertype, runnerconfig in config.items():
             if runnertype != "global":
-                if (self.verbose):
-                    print(f"[Alice] Global config found for runner {runnertype}")
+                logging.info(f"[Alice] Global config found for runner {runnertype}")
                 config = self.globals.copy()
                 for key, value in runnerconfig.items():
                     if key == "env":
@@ -41,18 +41,15 @@ class Factory():
                     else:
                         config[key] = value
                 self.runner_configs[runnertype] = config
+                logging.debug(f"[Alice] Globals for {runnertype}: {runnerconfig}")
 
     def get_runner(self, runnertype):
         if runnertype not in self.runners:
             if runnertype in self.runnertypes:
-                if (self.verbose):
-                    print(f"[Alice] Initializing runner: {runnertype}")
-                params = {
-                    "verbose": self.verbose
-                }
+                logging.info(f"[Alice] Initializing runner: {runnertype}")
                 # If there is a runner specific config, use that, else global
                 config = self.runner_configs.get(runnertype, self.globals.copy())
-                self.runners[runnertype] = self.runnertypes[runnertype](params, config)
+                self.runners[runnertype] = self.runnertypes[runnertype](config)
             else:
                 raise ConfigException(f"Invalid runner type: {runnertype}")
         return self.runners[runnertype]
diff --git a/alice-ci/src/alice/runners/__init__.py b/alice-ci/src/alice/runners/__init__.py
index b67238e9aa4f1d1f4e48cf95890529eae747f964..8c0f819372b394f4aede1839519a538c06684ced 100644
--- a/alice-ci/src/alice/runners/__init__.py
+++ b/alice-ci/src/alice/runners/__init__.py
@@ -1,3 +1,4 @@
 # flake8: noqa F401
-from alice.runners.pythonrunner import PythonRunner
-from alice.runners.pypirunner import PyPiRunner
+from .pythonrunner import PythonRunner
+from .pypirunner import PyPiRunner
+from .dockerrunner import DockerRunner
diff --git a/alice-ci/src/alice/runners/dockerrunner.py b/alice-ci/src/alice/runners/dockerrunner.py
index 50a3260f25ae81e207d31a225c29e1a11b30b1a6..7383f7f2a381156bd1c97e79053bed8e4881fa66 100644
--- a/alice-ci/src/alice/runners/dockerrunner.py
+++ b/alice-ci/src/alice/runners/dockerrunner.py
@@ -1 +1,241 @@
-# TODO Implement
+from enum import Enum
+import json
+import logging
+from os import path, getcwd
+import docker
+
+from .pyutils import grab_from, gen_dict
+from ..exceptions import ConfigException, NonZeroRetcode, RunnerError
+
+
+class ImageSource(Enum):
+    NONE = 1
+    BUILD = 2
+    PULL = 3
+
+
+def get_user(config, default):
+    if "credentials" in config:
+        if "username" in config["credentials"]:
+            data = config["credentials"]["username"]
+            if isinstance(data, str):
+                return data
+            else:
+                return grab_from(data)
+    return default
+
+
+def get_pass(config, default):
+    if "credentials" in config:
+        if "password" in config["credentials"]:
+            data = config["credentials"]["password"]
+            if isinstance(data, str):
+                return data
+            else:
+                return grab_from(data)
+    return default
+
+
+def get_provider(config, default, default_type):
+    if "image" in config:
+        build = False
+        pull = False
+        candidate_type = default_type
+        if "build" in config["image"]:
+            build = True
+            if default_type == ImageSource.BUILD:
+                candidate = default.copy(config["image"]["build"])
+            else:
+                candidate = Builder(config["image"]["build"])
+                candidate_type = ImageSource.BUILD
+        elif "pull" in config["image"]:
+            pull = True
+            if default_type == ImageSource.PULL:
+                candidate = default.copy(config["image"]["pull"])
+            else:
+                candidate = Puller(config["image"]["pull"])
+                candidate_type = ImageSource.PULL
+
+        if build and pull:
+            raise ConfigException("[DockerRunner] Can't build and pull the same image!")
+
+        return candidate, candidate_type
+    return default, default_type
+
+
+class Tagger:
+    def __init__(self, config={}) -> None:
+        self.name = config.get("name", None)
+        self.username = get_user(config, None)
+        self.password = get_pass(config, None)
+        self.publish = config.get("publish", False)
+
+    def copy(self, job_config):
+        t = Tagger()
+        t.name = job_config.get("name", self.name)
+        t.username = get_user(job_config, self.username)
+        t.password = get_pass(job_config, self.password)
+        t.publish = job_config.get("publish", self.publish)
+        return t
+
+    def __str__(self) -> str:
+        data = {
+            "name": self.name,
+            "publish": self.publish,
+            "credentials": {
+                "username": self.username,
+                "password": self.password
+            }
+        }
+        return f"{data}"
+
+    def handle(self, client, image):
+        if self.name is not None:
+            if self.name not in image.tags and f"{self.name}:latest" not in image.tags:
+                print(f"[DockerRunner] Tagging {image.tags[0]} as {self.name}")
+                image.tag(self.name)
+        if self.publish:
+            print(f"[DockerRunner] Pushing {self.name}")
+            client.push(self.name)
+
+
+class Builder():
+    def __init__(self, config) -> None:
+        self.dir = path.abspath(config.get("dir", getcwd()))
+        self.dockerfile = config.get("dockerfile", None)
+        self.name = config.get("name", None)
+        self.args = gen_dict(config.get("args", []))
+
+    def copy(self, job_config):
+        b = Builder({})
+        b.dir = path.abspath(path.join(self.dir, job_config.get("dir", ".")))
+        b.dockerfile = job_config.get("dockerfile", self.dockerfile)
+        b.name = job_config.get("name", self.name)
+        b.args = self.args.copy().update(gen_dict(job_config.get("args", [])))
+        return b
+
+    def __str__(self) -> str:
+        data = {
+            "type": "builder",
+            "dir": self.dir,
+            "dockerfile": self.dockerfile,
+            "name": self.name,
+            "args": self.args
+        }
+        return json.dumps(data)
+
+    def prepare(self, client):
+        print(f"[DockerRunner] Building image {self.name}")
+        if self.dockerfile is None:
+            self.dockerfile = "Dockerfile"
+        try:
+            image, log = client.images.build(path=self.dir,
+                                             dockerfile=self.dockerfile,
+                                             tag=self.name,
+                                             buildargs=self.args,
+                                             labels={"builder": "alice-ci"})
+            for i in log:
+                logging.debug(i)
+            return image
+        except docker.errors.BuildError as e:
+            raise RunnerError(f"[DockerRunner] Build failed: {e}")
+        except docker.errors.APIError as e:
+            raise RunnerError(f"[DockerRunner] Error: {e}")
+
+
+class Puller():
+    def __init__(self, config={}) -> None:
+        self.name = config.get("name", None)
+        self.username = get_user(config, None)
+        self.password = get_pass(config, None)
+
+    def copy(self, job_config={}):
+        p = Puller()
+        p.name = job_config.get("name", self.name)
+        p.username = get_user(job_config, self.username)
+        p.password = get_pass(job_config, self.password)
+
+    def __str__(self) -> str:
+        data = {
+            "name": self.name,
+            "credentials": {
+                "username": self.username,
+                "password": self.password
+            }
+        }
+        return f"{data}"
+
+    def prepare(self, client):
+        print(f"[DockerRunner] Pulling image {self.name}")
+        return client.images.pull(self.name)
+
+
+class DockerConfig:
+    def __init__(self, config={}) -> None:
+        self.username = get_user(config, None)
+        self.password = get_pass(config, None)
+        self.image_provider, self.provider_type = get_provider(config, None, ImageSource.NONE)
+        self.tagger = Tagger(config.get("tag", {}))
+        self.commands = config.get("commands", [])
+        self.env = config.get("env", {})
+
+    def copy(self, job_config={}):
+        d = DockerConfig()
+        d.username = get_user(job_config, self.username)
+        d.password = get_pass(job_config, self.password)
+        d.image_provider, d.provider_type = get_provider(job_config, self.image_provider, self.provider_type)
+        d.tagger = self.tagger.copy(job_config.get("tag", {}))
+        d.commands = self.commands.copy() + job_config.get("commands", [])
+        d.env = self.env.copy()
+        d.env.update(gen_dict(job_config.get("env", [])))
+        return d
+
+    def __str__(self) -> str:
+        data = {
+            "credentials": {
+                "username": {self.username},
+                "password": {self.password}
+            },
+            "image": self.image_provider.__str__(),
+            "commands": self.commands,
+            "tag": self.tagger.__str__()
+        }
+        return f"{data}"
+
+
+class DockerRunner():
+    def __init__(self, config) -> None:
+        logging.info("[DockerRunner] Initializing")
+        self.config = DockerConfig(config)
+        self.client = docker.from_env()
+
+    def run(self, job_spec):
+        job_config = self.config.copy(job_spec)
+        logging.debug(f"[DockerRunner] Job config: {job_config.__str__()}")
+        if job_config.image_provider is None:
+            raise RunnerError("[DockerRunner] No image provider configured!")
+        image = job_config.image_provider.prepare(self.client)
+        logging.info(f"[DockerRunner] Image: {image.tags} ({image.id})")
+
+        if len(job_config.commands) > 0:
+            if "PATH" in job_config.env:
+                del job_config.env["PATH"]
+            container = self.client.containers.run(image=image.id,
+                                                   entrypoint=["sleep", "infinity"],
+                                                   detach=True,
+                                                   auto_remove=True)
+            try:
+                for i in job_config.commands:
+                    command = ["/bin/sh", "-c", i]
+                    logging.debug(f"[DockerRunner] Command array: {command}")
+                    code, output = container.exec_run(cmd=command,
+                                                      environment=job_config.env)
+                    for line in output.decode("UTF-8").splitlines():
+                        print(f"[{job_spec['name']}] {line}")
+                    if code != 0:
+                        raise NonZeroRetcode(f"Command {i} returned code {code}")
+            finally:
+                if container is not None:
+                    container.stop()
+
+        job_config.tagger.handle(self.client, image)
diff --git a/alice-ci/src/alice/runners/pypirunner.py b/alice-ci/src/alice/runners/pypirunner.py
index fb821c328aef37110abdb119ae7fa48c77831e29..63d0d04e0d79995161520ea24b2b4827ff9f4cef 100644
--- a/alice-ci/src/alice/runners/pypirunner.py
+++ b/alice-ci/src/alice/runners/pypirunner.py
@@ -1,4 +1,5 @@
 import json
+import logging
 import os
 import re
 import subprocess
@@ -6,17 +7,10 @@ import sys
 from urllib import request, error
 from pkg_resources import parse_version
 from os import environ, path
-from alice.runners.pyutils import PackageManager, glob
+from alice.runners.pyutils import PackageManager, glob, grab_from
 from alice.exceptions import ConfigException, RunnerError
 
 
-def grab_from(target):
-    if "from_env" in target:
-        return environ[target["from_env"]]
-    else:
-        raise ConfigException(f"Unsupported grabber: {target.keys()}")
-
-
 def get_uri(config, default):
     url = config.get("repo", {}).get("uri", default)
     if url is not None:
@@ -75,10 +69,8 @@ class PypiConfig:
 
 # TODO: consider "--skip-existing" flag for twine
 class PyPiRunner():
-    def __init__(self, params, config) -> None:
-        self.verbose = params["verbose"]
-        if self.verbose:
-            print("[PyPiRunner] Initializing")
+    def __init__(self, config) -> None:
+        logging.info("[PyPiRunner] Initializing")
         self.workdir = config["workdir"]
         self.config = PypiConfig(config)
 
@@ -95,7 +87,6 @@ class PyPiRunner():
         return sorted(releases, key=parse_version, reverse=True)
 
     def build(self, config, package):
-        # TODO: Actual build - silent, unless failure!
         pkg_path = path.join(config.workdir, package)
         if not path.isdir(pkg_path):
             raise ConfigException(f"Path does not exists: {pkg_path}")
@@ -126,9 +117,7 @@ class PyPiRunner():
 
 
     def upload(self, config, package):
-        command = [sys.executable, "-m", "twine", "upload"]
-        if self.verbose:
-            command.append("--verbose")
+        command = [sys.executable, "-m", "twine", "upload", "--verbose"]
         if config.repo_uri is not None:
             command.append("--repository-url")
             command.append(config.repo_uri)
@@ -163,7 +152,7 @@ class PyPiRunner():
         PackageManager.getInstance().ensure("build")
         for package in job_config.packages:
             print(f"[PyPiRunner] Building {package}")
-            #self.build(job_config, package)
+            self.build(job_config, package)
             print(f"[PyPiRunner] Package {package} built")
 
         if job_config.upload:
diff --git a/alice-ci/src/alice/runners/pythonrunner.py b/alice-ci/src/alice/runners/pythonrunner.py
index 18011fc85d7cf6faeaa9ee4d09fdbcc61b9a36ff..ea1351ab81298e7c679ed2ef94e64d66f14026a4 100644
--- a/alice-ci/src/alice/runners/pythonrunner.py
+++ b/alice-ci/src/alice/runners/pythonrunner.py
@@ -1,21 +1,21 @@
+import logging
 import subprocess
 import os
 import sys
 import shlex
 
-from alice.exceptions import NonZeroRetcode, RunnerError, ConfigException
-from alice.runners.pyutils import glob_command
+from ..exceptions import NonZeroRetcode, RunnerError, ConfigException
+from .pyutils import PackageManager, glob_command
 
 
 # TODO: Handle config like PyPiConfig
 class PythonRunner:
-    def __init__(self, params, config) -> None:
-        self.verbose = params["verbose"]
-        if self.verbose:
-            print("[PythonRunner] Initializing")
+    def __init__(self, config) -> None:
+        logging.info("[PythonRunner] Initializing")
         self.workdir = config["workdir"]
         self.virtual_dir = os.path.abspath(os.path.join(self.workdir, "venv"))
         self.config = config
+        PackageManager.getInstance().ensure("build")
         self.__init_venv()
 
     def __init_venv(self):
@@ -25,7 +25,7 @@ class PythonRunner:
             self.vpython = os.path.join(self.virtual_dir, "bin", "python3")
 
         if not os.path.exists(self.vpython):
-            print("[PythonRunner] Initializing venv")
+            logging.info("[PythonRunner] Initializing venv")
             with subprocess.Popen([sys.executable, "-m", "virtualenv", self.virtual_dir],
                                   stdout=subprocess.PIPE, stderr=subprocess.PIPE) as p:
                 p.wait()
@@ -33,26 +33,22 @@ class PythonRunner:
                     sys.stdout.buffer.write(p.stderr.read())
                     raise RunnerError("[PythonRunner] Could not create virtualenv")
                 else:
-                    if self.verbose:
-                        print(f"[PythonRunner] Virtualenv initialized at {self.virtual_dir}")
+                    logging.info(f"[PythonRunner] Virtualenv initialized at {self.virtual_dir}")
         else:
-            if self.verbose:
-                print(f"[PythonRunner] Found virtualenv at {self.virtual_dir}")
+            logging.info(f"[PythonRunner] Found virtualenv at {self.virtual_dir}")
         dependencies = self.config.get("dependencies", [])
         if len(dependencies) > 0:
-            if self.verbose:
-                print(f"[PythonRunner] Ensuring dependencies:  {', '.join(dependencies)}")
+            logging.info(f"[PythonRunner] Ensuring dependencies:  {', '.join(dependencies)}")
             command = [self.vpython, "-m", "pip", "install"] + dependencies
             with subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as p:
                 p.wait()
                 if p.returncode != 0:
                     sys.stdout.buffer.write(p.stderr.read())
                     raise(RunnerError(f"[PythonRunner] Could not install dependencies: {dependencies} ({p.returncode})"))
-            if self.verbose:
-                print("[PythonRunner] Installation done")
+            logging.info("[PythonRunner] Installation done")
 
     # Executes the given job in the one and only venv
-    # parameter shall be the raw jobscpec
+    # parameter is the raw jobscpec
     def run(self, job_spec):
         if "workdir" in job_spec:
             pwd = os.path.abspath(os.path.join(self.workdir, job_spec["workdir"]))
@@ -65,16 +61,14 @@ class PythonRunner:
         if "commands" in job_spec:
             commands = job_spec["commands"]
             for command in commands:
-                if self.verbose:
-                    print(f"[PythonRunner] Raw command: {command}")
+                logging.debug(f"[PythonRunner] Raw command: {command}")
                 # TODO: only split if command is not an array
                 if "*" in command:
-                    run_command = glob_command(shlex.split(command), pwd, self.verbose)
+                    run_command = glob_command(shlex.split(command), pwd)
                 else:
                     run_command = shlex.split(command)
-                if self.verbose:
-                    print(f"[PythonRunner] Command to execute: {run_command}")
-                    print(f"[PythonRunner] Workdir: {pwd}")
+                logging.info(f"[PythonRunner] Command to execute: {run_command}")
+                logging.debug(f"[PythonRunner] Workdir: {pwd}")
                 if os.path.isdir(pwd):
                     with subprocess.Popen([self.vpython] + run_command, cwd=pwd, env=run_env) as p:
                         p.wait()
diff --git a/alice-ci/src/alice/runners/pyutils.py b/alice-ci/src/alice/runners/pyutils.py
index 62efdf7b40b915d88fbee0f45ea0a7afc3b2fd5b..e37fb604100cf9ee6ded773110fd1b7e5d483df7 100644
--- a/alice-ci/src/alice/runners/pyutils.py
+++ b/alice-ci/src/alice/runners/pyutils.py
@@ -1,10 +1,12 @@
+import logging
 import os
 import subprocess
 import sys
 from pkg_resources import parse_version
 import re
 
-from alice.exceptions import RunnerError, ConfigException
+from ..exceptions import RunnerError, ConfigException
+from ..config import ConfigHolder
 
 
 class PackageManager:
@@ -23,7 +25,7 @@ class PackageManager:
             raise Exception("This class is a singleton!")
         else:
             PackageManager.__instance = self
-        self.package_list = self.__get_packages()
+            self.package_list = self.__get_packages()
 
     def __get_packages(self):
         packages = {}
@@ -83,11 +85,10 @@ class PackageManager:
         return False
 
 
-def glob(item, workdir, verbose=False):
+def glob(item, workdir):
     new_command = []
     if "*" in item:
-        if verbose:
-            print(f"[Globbing] Found item: [{item}]")
+        logging.debug(f"[Globbing] Found item: [{item}]")
         dir = os.path.abspath(os.path.join(workdir, os.path.dirname(item)))
         base_name = os.path.basename(item)
         if os.path.isdir(dir):
@@ -96,8 +97,7 @@ def glob(item, workdir, verbose=False):
                 # TODO: Fix ordering! A*B = B*A = AB*
                 if item_parts[0] in file and item_parts[1] in file:
                     new_item = os.path.join(dir, file)
-                    if verbose:
-                        print(f"[Globbing] Substitute: {new_item}")
+                    logging.debug(f"[Globbing] Substitute: {new_item}")
                     new_command.append(new_item)
         else:
             raise ConfigException(f"[Globbing] Dir not exists: {dir}")
@@ -106,10 +106,42 @@ def glob(item, workdir, verbose=False):
         return [item]
 
 
-def glob_command(command, workdir, verbose=False):
-    if verbose:
-        print(f"[Globbing] Starting command: {' '.join(command)}")
+def glob_command(command, workdir):
+    logging.debug(f"[Globbing] Starting command: {' '.join(command)}")
     new_command = []
     for item in command:
-        new_command += glob(item, workdir, verbose)
+        new_command += glob(item, workdir)
     return new_command
+
+
+def grab_from(target):
+    if "from_env" in target:
+        try:
+            return os.environ[target["from_env"]]
+        except KeyError:
+            raise ConfigException(f"Env var unset: {target['from_env']}")
+    elif "from_cfg" in target:
+        ConfigHolder.getInstance().get(target["from_cfg"])
+    else:
+        raise ConfigException(f"Unsupported grabber: {target.keys()}")
+
+
+def gen_dict(list_of_dicts):
+    """
+    Generates a dictionary from a list of dictionaries composed of
+    'name' and 'value' keys.
+
+    [{'name': 'a', 'value': 'b'}] => {'a': 'b'}
+    """
+    return_dict = {}
+
+    for _dict in list_of_dicts:
+        try:
+            if isinstance(_dict["value"], str):
+                return_dict[_dict["name"]] = _dict["value"]
+            else:
+                return_dict[_dict["name"]] = grab_from(_dict["value"])
+        except KeyError:
+            raise ConfigException(f"Invalid dict item: {_dict}")
+
+    return return_dict
diff --git a/ci-examples/full.yaml b/ci-examples/full.yaml
index 108ba9299642e0ecea4ad0543354ef719ba033d6..0e5714fb79ee59e68cec150695478e1bfe4b12ae 100644
--- a/ci-examples/full.yaml
+++ b/ci-examples/full.yaml
@@ -15,6 +15,11 @@ runners:
     dependencies:
       - flake8
       - build
+  docker:
+    credentials:
+      username: D
+      password: D
+
 jobs:
   - name: env
     type: python
@@ -46,6 +51,40 @@ jobs:
         from_env: PYPIPASS
     packages:
      - alice-ci
+  - name: "image"
+    type: docker
+    credentials:
+      username: A
+      #password: B
+    image:
+      build:
+        dir: ci-examples/images/hello
+        #dockerfile: ci-examples/images/hello/Dockerfile
+        dockerfile: Dockerfile
+        name: "sssss"
+        args:
+          - name: CIPASS
+            value: NONE
+      #pull:
+        #name: python:latest
+        #credentials:
+          #username: PASS
+          #password: WORD
+    env:
+      - name: VAR
+        value: CHAR
+    commands:
+      - which python3
+      - /usr/bin/python3 --version
+      - date
+      - env
+    tag:
+      publish: false
+      name: repo.example.com/test/na
+      credentials:
+        username: B
+        password: B
+    
 pipelines:
   default:
     - lint
diff --git a/ci-examples/images/hello/Dockerfile b/ci-examples/images/hello/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..17cd4aa2e1b015ea1d9dc0ec1c1b4ecb5497d0aa
--- /dev/null
+++ b/ci-examples/images/hello/Dockerfile
@@ -0,0 +1,9 @@
+FROM ubuntu:latest
+
+RUN apt update && apt install -y python3
+
+ADD hello.py /opt/hello.py
+
+#ENTRYPOINT [ "/bin/sh", "-c" ]
+
+#CMD ["/usr/local/python/bin/python3", "/opt/hello.py"]
\ No newline at end of file
diff --git a/ci-examples/images/hello/hello.py b/ci-examples/images/hello/hello.py
new file mode 100644
index 0000000000000000000000000000000000000000..617e813c91df405b165643c8ed49c3f2749a6296
--- /dev/null
+++ b/ci-examples/images/hello/hello.py
@@ -0,0 +1,2 @@
+if __name__ == "__main__":
+    print("Hi Mom!")
diff --git a/docs/runners/docker.md b/docs/runners/docker.md
new file mode 100644
index 0000000000000000000000000000000000000000..0ae0853d5aca36e45d5d53c16d4d71fb274a1d6b
--- /dev/null
+++ b/docs/runners/docker.md
@@ -0,0 +1,26 @@
+# Schema
+
+```
+name: ""
+type: docker
+credentials: - global ...ish
+  username
+  password
+image: - to use, pull, run
+  build:
+    dir:
+    dockerfile:
+    name: - defaults to step name
+    args:
+      - name:
+      - value:
+  pull:  - pulls, current working image - mutually exclusive with build
+    name:
+    credentials: - optional
+command: - overwrite, not append
+  - ...
+tag:
+  publish: true
+  name: - published name with repo and everything
+  credentials:
+```
\ No newline at end of file