diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..a8cedc3e33983d98849c9743ed01a793588398f1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.env
+__pycache__
+containers.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..6ff7208495778c7a60b6071c5b54b1eea13c2ad7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,29 @@
+# Der Lustmolch
+Allgemeiner container host für (semi-offizielle) Stusta websites.
+
+## How-To
+Alle management Befehle laufen entweder über `lustmolch.py` oder über `machinectl`.
+
+### Container erstellen
+```bash
+python3 lustmolch.py create-container <container-name>
+```
+Der container wird als basic debian image in `/var/lib/machines` angelegt und `bootstrap.sh` wird ausgeführt.
+Dabei werden nötige Pakete installiert, alle Konfigurationsfiles sowohl auf dem host als auch im Container abgelegt. 
+Außerdem wird im container **openssh-server** installiert und gestartet. Der Port wird dynamisch auf den ersten 
+freien Port ab **10022** in Inkrementen von **1000** gesetzt.
+
+Die templates für Konfigurationsfiles liegen im directory **container**.
+
+### Container VERNICHTEN
+```bash
+python3 lustmolch.py remove-container <container-name>
+```
+Der Container und alle Konfigurationsfiles auf dem Host werden gelöscht.
+
+### SSH Key installieren
+```bash
+python3 lustmolch.py install-ssh-key <container-name> <path-to-ssh-key>
+```
+Der angegeben SSH key wird in `/root/.ssh/authorized_keys` kopiert. Es ist möglich den key als String-Parameter
+zu übergeben, dabei muss das Flag `--key-string` gesetzt sein.
\ No newline at end of file
diff --git a/Readme.md b/Readme.md
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/container/bootstrap.sh b/container/bootstrap.sh
new file mode 100755
index 0000000000000000000000000000000000000000..7d85d9e3f763512b7c4a25bd567e4e039146da2b
--- /dev/null
+++ b/container/bootstrap.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+apt-get update
+apt-get -y upgrade
+apt-get -y install dbus openssh-server vim
\ No newline at end of file
diff --git a/container/nginx b/container/nginx
new file mode 100644
index 0000000000000000000000000000000000000000..8272ec325e55b9ac2ce9d956c4f0f2ab2fd984c7
--- /dev/null
+++ b/container/nginx
@@ -0,0 +1,9 @@
+server {
+    listen 80;
+    server_name {{name}}.stusta.de;
+    charset utf-8;
+
+    location /static {
+        alias /var/www/{{name}};
+    }
+}
\ No newline at end of file
diff --git a/container/nspawn b/container/nspawn
new file mode 100644
index 0000000000000000000000000000000000000000..bc2699962bc872fceab01751a7fc440e35b51a40
--- /dev/null
+++ b/container/nspawn
@@ -0,0 +1,8 @@
+[Network]
+VirtualEthernet=no
+
+[Files]
+Bind=/var/www/{{name}}:/var/www
+
+[Exec]
+PrivateUsers=off
\ No newline at end of file
diff --git a/container/sshd_config b/container/sshd_config
new file mode 100644
index 0000000000000000000000000000000000000000..6c252b8afc128f290c2956866c41aab5349ec3c7
--- /dev/null
+++ b/container/sshd_config
@@ -0,0 +1,121 @@
+#	$OpenBSD: sshd_config,v 1.103 2018/04/09 20:41:22 tj Exp $
+
+# This is the sshd server system-wide configuration file.  See
+# sshd_config(5) for more information.
+
+# This sshd was compiled with PATH=/usr/bin:/bin:/usr/sbin:/sbin
+
+# The strategy used for options in the default sshd_config shipped with
+# OpenSSH is to specify options with their default value where
+# possible, but leave them commented.  Uncommented options override the
+# default value.
+
+Port {{ssh_port}}
+#AddressFamily any
+#ListenAddress 0.0.0.0
+#ListenAddress ::
+
+#HostKey /etc/ssh/ssh_host_rsa_key
+#HostKey /etc/ssh/ssh_host_ecdsa_key
+#HostKey /etc/ssh/ssh_host_ed25519_key
+
+# Ciphers and keying
+#RekeyLimit default none
+
+# Logging
+#SyslogFacility AUTH
+#LogLevel INFO
+
+# Authentication:
+
+#LoginGraceTime 2m
+PermitRootLogin prohibit-password
+#StrictModes yes
+#MaxAuthTries 6
+#MaxSessions 10
+
+PubkeyAuthentication yes
+
+# Expect .ssh/authorized_keys2 to be disregarded by default in future.
+#AuthorizedKeysFile	.ssh/authorized_keys .ssh/authorized_keys2
+
+#AuthorizedPrincipalsFile none
+
+#AuthorizedKeysCommand none
+#AuthorizedKeysCommandUser nobody
+
+# For this to work you will also need host keys in /etc/ssh/ssh_known_hosts
+#HostbasedAuthentication no
+# Change to yes if you don't trust ~/.ssh/known_hosts for
+# HostbasedAuthentication
+#IgnoreUserKnownHosts no
+# Don't read the user's ~/.rhosts and ~/.shosts files
+#IgnoreRhosts yes
+
+# To disable tunneled clear text passwords, change to no here!
+#PasswordAuthentication yes
+#PermitEmptyPasswords no
+
+# Change to yes to enable challenge-response passwords (beware issues with
+# some PAM modules and threads)
+ChallengeResponseAuthentication no
+
+# Kerberos options
+#KerberosAuthentication no
+#KerberosOrLocalPasswd yes
+#KerberosTicketCleanup yes
+#KerberosGetAFSToken no
+
+# GSSAPI options
+#GSSAPIAuthentication no
+#GSSAPICleanupCredentials yes
+#GSSAPIStrictAcceptorCheck yes
+#GSSAPIKeyExchange no
+
+# Set this to 'yes' to enable PAM authentication, account processing,
+# and session processing. If this is enabled, PAM authentication will
+# be allowed through the ChallengeResponseAuthentication and
+# PasswordAuthentication.  Depending on your PAM configuration,
+# PAM authentication via ChallengeResponseAuthentication may bypass
+# the setting of "PermitRootLogin without-password".
+# If you just want the PAM account and session checks to run without
+# PAM authentication, then enable this but set PasswordAuthentication
+# and ChallengeResponseAuthentication to 'no'.
+UsePAM yes
+
+#AllowAgentForwarding yes
+#AllowTcpForwarding yes
+#GatewayPorts no
+X11Forwarding yes
+#X11DisplayOffset 10
+#X11UseLocalhost yes
+#PermitTTY yes
+PrintMotd no
+#PrintLastLog yes
+#TCPKeepAlive yes
+#PermitUserEnvironment no
+#Compression delayed
+#ClientAliveInterval 0
+#ClientAliveCountMax 3
+#UseDNS no
+#PidFile /var/run/sshd.pid
+#MaxStartups 10:30:100
+#PermitTunnel no
+#ChrootDirectory none
+#VersionAddendum none
+
+# no default banner path
+#Banner none
+
+# Allow client to pass locale environment variables
+AcceptEnv LANG LC_*
+
+# override default of no subsystems
+Subsystem	sftp	/usr/lib/openssh/sftp-server
+
+# Example of overriding settings on a per-user basis
+#Match User anoncvs
+#	X11Forwarding no
+#	AllowTcpForwarding no
+#	PermitTTY no
+#	ForceCommand cvs server
diff --git a/lustmolch.py b/lustmolch.py
new file mode 100755
index 0000000000000000000000000000000000000000..28ad4604089c70f80a9d0ebb6f77da80f9a38aa1
--- /dev/null
+++ b/lustmolch.py
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+import json
+import shutil
+from collections import namedtuple
+from subprocess import run
+from pathlib import Path
+from jinja2 import Environment, PackageLoader
+import click
+
+
+env = Environment(loader=PackageLoader('lustmolch', 'container'))
+cfg_template = namedtuple('cfg_template', ['source', 'path', 'filename'])
+
+template_files_host = [
+    cfg_template('nginx', Path('/etc/nginx/sites-available'), '{name}'),
+    cfg_template('nspawn', Path('/etc/systemd/nspawn'), '{name}.nspawn')
+]
+template_files_container = [
+    cfg_template('sshd_config', Path('/etc/ssh'), 'sshd_config')
+]
+
+FLAVOUR = 'buster'
+DEBIAN_MIRROR = 'http://mirror.stusta.de/debian'
+
+www_root = Path('/var/www')
+
+SSH_START_PORT = 10022
+SSH_PORT_INCREMENT = 1000
+
+
+def next_ssh_port(config_file, name):
+    """
+    Return the next available port for the containers ssh server to run on.
+    If the container is already present in the list of installed containers
+    returns the configured port.
+    Args:
+        config_file: Path to container configuration file (containers.json)
+        name: Container name
+
+    Returns: SSH port
+
+    """
+    if not Path(config_file).exists():
+        cfg = {}
+    else:
+        with open(config_file, 'r') as f:
+            cfg = json.load(f)
+
+    if name in cfg:
+        return cfg.get(name).get('ssh_port')
+    port = SSH_START_PORT
+    for container in cfg.items():
+        if container.get('ssh_port') >= port:
+            port = container.get('ssh_port') + SSH_PORT_INCREMENT
+
+    return port
+
+
+def update_config(config_file, name, container):
+    if not Path(config_file).exists():
+        with open(config_file, 'w+') as f:
+            cfg = {name: container}
+            json.dump(cfg, f, indent=4)
+    else:
+        with open(config_file, 'r') as f:
+            cfg = json.load(f)
+            cfg[name] = container
+        with open(config_file, 'w') as f:
+            json.dump(cfg, f, indent=4)
+
+
+@click.group()
+def cli():
+    pass
+
+
+@cli.command()
+@click.option('--dry-run', is_flag=True, default=False)
+@click.option('--config-file', default='containers.json', help='Container configuration file')
+@click.argument('name')
+def create_container(dry_run, config_file, name):
+    if dry_run:
+        click.echo(f'Doing a dry run')
+
+    # create shared folder for html static files
+    www_dir = www_root / name
+    click.echo(f'Creating shared www directory "{www_dir}"')
+    if not dry_run:
+        www_dir.mkdir(parents=True, exist_ok=True)
+
+    # place configuration files
+    context = {
+        'name': name,
+        'ssh_port': next_ssh_port(config_file, name)
+    }
+    for cfg in template_files_host:
+        template = env.get_template(cfg.source)
+        file_name = cfg.path / (cfg.filename.format(**context))
+        click.echo(f'Placing config file {file_name}')
+        if not dry_run:
+            with open(file_name, 'w+') as cfg_file:
+                cfg_file.write(template.render(context))
+
+    # create machine
+    machine_path = Path('/var/lib/machines', name)
+    click.echo(f'Running debootstrap')
+    if not dry_run:
+        run(['debootstrap', FLAVOUR, machine_path, DEBIAN_MIRROR], capture_output=True, check=True)
+
+    # start container for the first time
+    # click.echo(f'Starting container for the first time')
+    # if not dry_run:
+    #     run(['systemd-nspawn', '-D', machine_path], check=True)
+
+    click.echo(f'Bootstrapping container')
+    if not dry_run:
+        # copy and run bootstrap shell script
+        script_location = '/opt/bootstrap.sh'
+        script_location_host = str(machine_path) + script_location
+
+        shutil.copy('container/bootstrap.sh', script_location_host)
+        Path(script_location_host).chmod(0o755)
+        run(['systemd-nspawn', '-D', str(machine_path), script_location], check=True)
+
+    click.echo(f'Copying config files into container')
+    if not dry_run:
+        for cfg in template_files_container:
+            template = env.get_template(cfg.source)
+            file_name = cfg.filename.format(**context)
+            click.echo(f'Placing config file {file_name}')
+            if not dry_run:
+                with open(Path(f'{machine_path}{cfg.path}/{file_name}'), 'w+') as f:
+                    f.write(template.render(context))
+
+    click.echo(f'Starting container')
+    if not dry_run:
+        run(['machinectl', 'start', name], capture_output=True, check=True)
+
+    click.echo(f'Updating container configuration file')
+    if not dry_run:
+        update_config(config_file, name, container=context)
+
+    click.echo(f'All done, ssh server running on port {context["ssh_port"]}')
+
+
+@cli.command()
+@click.option('--config-file', default='containers.json', help='Container configuration file')
+@click.option('--key-string', is_flag=True, default=False)
+@click.argument('name')
+@click.argument('key')
+def install_ssh_key(config_file, key_string, name, key):
+    ssh_dir = Path('/var/lib/machines', name, 'root/.ssh')
+    authorized_keys = ssh_dir / 'authorized_keys'
+    if key_string:
+        key_string = key
+    else:
+        with open(key, 'r') as f:
+            key_string = f.read()
+
+    click.echo(f'Appending ssh key\n{key_string} to {authorized_keys}')
+    ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
+    with open(authorized_keys, 'a+') as f:
+        f.write(key_string)
+    authorized_keys.chmod(0o600)
+
+
+@cli.command()
+@click.option('--config-file', default='containers.json', help='Container configuration file')
+@click.argument('name')
+def remove_container(config_file, name):
+    machine_path = Path('/var/lib/machines', name)
+
+    # removing shared folder
+    www_dir = www_root / name
+    click.echo(f'Removing shared www folder')
+    try:
+        shutil.rmtree(www_dir, ignore_errors=True)
+    except OSError as e:
+        click.echo(f'{e} ignored when removing container')
+
+    # deleting placed config files
+    for cfg in template_files_host:
+        file_name = cfg.path / cfg.filename.format(name=name)
+        click.echo(f'Removing config file {file_name}')
+        try:
+            file_name.unlink()
+        except OSError as e:
+            click.echo(f'{e} ignored when removing file {file_name}')
+
+    # delete container itself
+    click.echo(f'Removing container')
+    try:
+        shutil.rmtree(machine_path, ignore_errors=True)
+    except OSError as e:
+        click.echo(f'{e} ignored when removing container')
+
+    # remove container from configuration file
+    click.echo(f'Updating configuration file')
+    try:
+        with open(config_file, 'r') as f:
+            cfg = json.load(f)
+            if name in cfg:
+                del cfg[name]
+                with open(config_file, 'w') as f:
+                    json.dump(cfg, f, indent=4)
+    except OSError as e:
+        click.echo(f'{e} ignored when updating config file')
+
+
+if __name__ == '__main__':
+    cli()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..f1fa21e7f6de1d8d5844854c800698263e5721c2
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+click >= 7.0
+jinja2 >= 2.10
\ No newline at end of file
diff --git a/sites-enabled/lustmolch b/sites-enabled/lustmolch
deleted file mode 100644
index 95e73387c2a738999e15e7ae11fa2dd238acab45..0000000000000000000000000000000000000000
--- a/sites-enabled/lustmolch
+++ /dev/null
@@ -1,10 +0,0 @@
-server {
-
-    listen 80;
-    server_name lustmolch.stusta.de;
-    charset utf-8;
-
-    location / {
-        alias /var/www/html;
-    }
-}
diff --git a/sites-enabled/pot b/sites-enabled/pot
deleted file mode 100644
index ed7b33213ab8111e97a5b3fdf4c4f02fbbdf86c1..0000000000000000000000000000000000000000
--- a/sites-enabled/pot
+++ /dev/null
@@ -1,13 +0,0 @@
-server {
-
-    listen 80;
-    server_name pot.stusta.de;
-    charset utf-8;
-
-    location / {
-        proxy_pass http://localhost:8000;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-    }
-}
diff --git a/www/index.html b/www/index.html
deleted file mode 100644
index 466da218492f2ece2e895996d0180b74c4e82529..0000000000000000000000000000000000000000
--- a/www/index.html
+++ /dev/null
@@ -1,10 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-  <meta charset="UTF-8">
-  <title>Lustmolch</title>
-</head>
-<body>
-  <h1>THIS IS LUSTMOLCH</h1>
-</body>
-</html>
\ No newline at end of file