Repository: spotty-cloud/spotty (language: python)

Issue:
New AWS configuration parameter: instanceProfileArn
This pull request addresses issue #42.

The code change is very simple: surface a new AWS configuration parameter `instanceProfileArn` to `spotty.yaml` which the user can optionally set. If the parameter is set, it'll be assigned to the EC2 instance. If not, Spotty will create a new one to be attached to the instance.

This fix is primarily useful in situations where users don't have permission to create IAM roles/instance profiles in their AWS environment.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index bad7665..bfa0f01 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ article on Medium for a real-world example.
 ## Installation
 
 Requirements:
-  * Python >=3.5
+  * Python >=3.6
   * AWS CLI (see [Installing the AWS Command Line Interface](http://docs.aws.amazon.com/cli/latest/userguide/installing.html)) 
   if you're using AWS
   * Google Cloud SDK (see [Installing Google Cloud SDK](https://cloud.google.com/sdk/install)) 
@@ -56,12 +56,12 @@ Use [pip](http://www.pip-installer.org/en/latest/) to install or upgrade Spotty:
     To connect to the running container via SSH, use the following command:
 
     ```bash
-    $ spotty ssh
+    $ spotty sh
     ```
 
     It runs a [tmux](https://github.com/tmux/tmux/wiki) session, so you can always detach this session using
     __`Ctrl + b`__, then __`d`__ combination of keys. To be attached to that session later, just use the
-    `spotty ssh` command again.
+    `spotty sh` command again.
 
     Also, you can run your custom scripts inside the Docker container using the `spotty run <SCRIPT_NAME>` command. Read more
     about custom scripts in the documentation: 
diff --git a/bin/spotty b/bin/spotty
index 4f052e7..95c8c75 100755
--- a/bin/spotty
+++ b/bin/spotty
@@ -1,50 +1,33 @@
 #!/usr/bin/env python
 
 import sys
-import argparse
 import logging
 import spotty
-from spotty.commands.aws import AwsCommand
-from spotty.commands.download import DownloadCommand
-from spotty.commands.gcp import GcpCommand
-from spotty.commands.run import RunCommand
-from spotty.commands.ssh import SshCommand
-from spotty.commands.status import StatusCommand
-from spotty.commands.stop import StopCommand
-from spotty.commands.sync import SyncCommand
-from spotty.helpers.commands import add_subparsers, get_custom_commands
-from spotty.commands.start import StartCommand
+from spotty.cli import get_parser
 from spotty.commands.writers.output_writrer import OutputWriter
 
 
-parser = argparse.ArgumentParser()
-parser.add_argument('-V', '--version', action='store_true', help='Display the version of the Spotty')
+parser = get_parser()
 
-commands = [
-    StartCommand,
-    StopCommand,
-    StatusCommand,
-    SshCommand,
-    RunCommand,
-    SyncCommand,
-    DownloadCommand,
-    AwsCommand,
-    GcpCommand,
-] + get_custom_commands()
-
-# add commands to the parser
-add_subparsers(parser, commands)
-
-# parse arguments
-args = parser.parse_args()
+args = sys.argv[1:]
 output = OutputWriter()
 
 # display the version
-display_version = args.version
-if display_version:
+if '-V' in args:
     output.write(spotty.__version__)
     sys.exit(0)
 
+# separate Spotty arguments from custom arguments
+custom_args = []
+if '--' in args:
+    dd_idx = args.index('--')
+    custom_args = args[(dd_idx + 1):]
+    args = args[:dd_idx]
+
+# parse arguments
+args = parser.parse_args(args)
+args.custom_args = custom_args
+
 # logging
 logging_level = logging.DEBUG if 'debug' in args and args.debug else logging.WARNING
 logging.basicConfig(level=logging_level, format='[%(levelname)s] %(message)s')
diff --git a/docs/.gitignore b/docs/.gitignore
deleted file mode 100644
index 666de10..0000000
--- a/docs/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-_site
-.sass-cache
-.jekyll-metadata
-Gemfile.lock
diff --git a/docs/404.html b/docs/404.html
deleted file mode 100644
index 8e468b0..0000000
--- a/docs/404.html
+++ /dev/null
@@ -1,25 +0,0 @@
----
-layout: default
-nav_order: 999
----
-
-<style type="text/css" media="screen">
-  .container {
-    margin: 10px auto;
-    max-width: 600px;
-    text-align: center;
-  }
-  h1 {
-    margin: 30px 0;
-    font-size: 4em;
-    line-height: 1;
-    letter-spacing: -1px;
-  }
-</style>
-
-<div class="container">
-  <h1>404</h1>
-
-  <p><strong>Page not found :(</strong></p>
-  <p>The requested page could not be found.</p>
-</div>
diff --git a/docs/CNAME b/docs/CNAME
deleted file mode 100644
index ba15a72..0000000
--- a/docs/CNAME
+++ /dev/null
@@ -1,1 +0,0 @@
-spotty.cloud
\ No newline at end of file
diff --git a/docs/Gemfile b/docs/Gemfile
deleted file mode 100644
index e794543..0000000
--- a/docs/Gemfile
+++ /dev/null
@@ -1,32 +0,0 @@
-source "https://rubygems.org"
-
-# Hello! This is where you manage which Jekyll version is used to run.
-# When you want to use a different version, change it below, save the
-# file and run `bundle install`. Run Jekyll with `bundle exec`, like so:
-#
-#     bundle exec jekyll serve
-#
-# This will help ensure the proper Jekyll version is running.
-# Happy Jekylling!
-gem "jekyll", "~> 3.7.4"
-
-# This is the default theme for new Jekyll sites. You may change this to anything you like.
-gem "minima", "~> 2.0"
-
-# If you want to use GitHub Pages, remove the "gem "jekyll"" above and
-# uncomment the line below. To upgrade, run `bundle update github-pages`.
-# gem "github-pages", group: :jekyll_plugins
-
-# If you have any plugins, put them here!
-group :jekyll_plugins do
-  gem "jekyll-feed", "~> 0.6"
-end
-
-# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
-gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby]
-
-# Performance-booster for watching directories on Windows
-gem "wdm", "~> 0.1.0" if Gem.win_platform?
-
-gem 'rake'
-gem 'jekyll-remote-theme'
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..8e7adfb
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = python -msphinx
+SPHINXPROJ    = spotty
+SOURCEDIR     = source
+BUILDDIR      = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
diff --git a/docs/_config.yml b/docs/_config.yml
deleted file mode 100644
index 4d03a61..0000000
--- a/docs/_config.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-remote_theme: pmarsceill/just-the-docs@v0.1.6
-
-title: Spotty
-description: ''
-baseurl: ''
-url: ''
-
-# build settings
-markdown: kramdown
-plugins:
-  - jekyll-feed
-  - jekyll-remote-theme
-
-# the theme settings
-search_enabled: true
-aux_links:
-  GitHub:
-    - https://github.com/apls777/spotty
-
-webrick:
-  headers:
-    Access-Control-Allow-Origin: '*'
diff --git a/docs/_includes/head.html b/docs/_includes/head.html
deleted file mode 100644
index 083cbc3..0000000
--- a/docs/_includes/head.html
+++ /dev/null
@@ -1,15 +0,0 @@
-<head>
-  <meta charset="UTF-8">
-  <meta http-equiv="X-UA-Compatible" content="IE=Edge">
-
-  <title>{{ page.title }} - {{ site.title }}</title>
-  <link rel="stylesheet" href="{{ "/assets/css/just-the-docs.css" | absolute_url }}">
-  {% if site.search_enabled != nil %}
-  <script type="text/javascript" src="{{ "/assets/js/vendor/lunr.min.js" | absolute_url }}"></script>
-  {% endif %}
-  <script type="text/javascript" src="{{ "/assets/js/just-the-docs.js" | absolute_url }}"></script>
-
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-
-  {% include head/custom.html %}
-</head>
diff --git a/docs/_includes/head/custom.html b/docs/_includes/head/custom.html
deleted file mode 100644
index d28b69d..0000000
--- a/docs/_includes/head/custom.html
+++ /dev/null
@@ -1,7 +0,0 @@
-<link rel="apple-touch-icon" sizes="180x180" href="{{ site.baseurl }}/apple-touch-icon.png">
-<link rel="icon" type="image/png" sizes="32x32" href="{{ site.baseurl }}/favicon-32x32.png">
-<link rel="icon" type="image/png" sizes="16x16" href="{{ site.baseurl }}/favicon-16x16.png">
-<link rel="manifest" href="{{ site.baseurl }}/site.webmanifest">
-<link rel="mask-icon" href="{{ site.baseurl }}/safari-pinned-tab.svg" color="#5bbad5">
-<meta name="msapplication-TileColor" content="#00aba9">
-<meta name="theme-color" content="#ffffff">
diff --git a/docs/_includes/nav.html b/docs/_includes/nav.html
deleted file mode 100644
index a7d264a..0000000
--- a/docs/_includes/nav.html
+++ /dev/null
@@ -1,44 +0,0 @@
-<nav>
-  <ul class="navigation-list">
-    {% assign pages_list = site.html_pages | sort:"nav_order" %}
-    {% for node in pages_list %}
-      {% unless node.nav_exclude %}
-        {% if node.parent == nil %}
-          <li class="navigation-list-item{% if page.url == node.url or page.parent == node.title or page.grand_parent == node.title %} active{% endif %}">
-            {% if page.parent == node.title or page.grand_parent == node.title %}
-              {% assign first_level_url = node.url | absolute_url %}
-            {% endif %}
-            <a href="{{ node.url | absolute_url }}" class="navigation-list-link{% if page.url == node.url %} active{% endif %}">{{ node.title }}</a>
-            {% if node.has_children %}
-              {% assign children_list = site.html_pages | sort:"nav_order" %}
-              <ul class="navigation-list-child-list ">
-                {% for child in children_list %}
-                  {% if child.parent == node.title %}
-                    <li class="navigation-list-item {% if page.url == child.url or page.parent == child.title %} active{% endif %}">
-                      {% if page.url == child.url or page.parent == child.title %}
-                        {% assign second_level_url = child.url | absolute_url %}
-                      {% endif %}
-                      <a href="{{ child.url | absolute_url }}" class="navigation-list-link{% if page.url == child.url %} active{% endif %}">{{ child.title }}</a>
-                      {% if child.has_children %}
-                        {% assign grand_children_list = site.html_pages | sort:"nav_order" %}
-                        <ul class="navigation-list-child-list">
-                          {% for grand_child in grand_children_list %}
-                            {% if grand_child.parent == child.title %}
-                              <li class="navigation-list-item {% if page.url == grand_child.url %} active{% endif %}">
-                                <a href="{{ grand_child.url | absolute_url }}" class="navigation-list-link{% if page.url == grand_child.url %} active{% endif %}">{{ grand_child.title }}</a>
-                              </li>
-                            {% endif %}
-                          {% endfor %}
-                        </ul>
-                      {% endif %}
-                    </li>
-                  {% endif %}
-                {% endfor %}
-              </ul>
-            {% endif %}
-          </li>
-        {% endif %}
-      {% endunless %}
-    {% endfor %}
-  </ul>
-</nav>
\ No newline at end of file
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
deleted file mode 100644
index 1fa3954..0000000
--- a/docs/_layouts/default.html
+++ /dev/null
@@ -1,83 +0,0 @@
-<!DOCTYPE html>
-
-<html lang="en-us">
-{% include head.html %}
-
-<body>
-  <style>
-    .site-title { line-height: 0; }
-    .site-title img { height: 3rem; }
-
-    @media (min-width: 46.25rem) {
-      .side-bar { padding-top: 5rem; }
-      .site-title { height: 5rem; padding: 1rem 2rem 0 1rem; border: 0; }
-      .site-title img { height: 100%; }
-    }
-  </style>
-
-  <div class="page-wrap">
-    <div class="side-bar">
-      <a href="{{ site.url }}{{ site.baseurl }}" class="site-title fs-6 text-grey-dk-300 lh-tight">
-        <img src="{{ "/assets/images/logo_400x130.png" | absolute_url }}" alt="{{ site.title }}" />
-      </a>
-      <span class="fs-3"><button class="js-main-nav-trigger navigation-list-toggle btn btn-outline" type="button" data-text-toggle="Hide">Menu</button></span>
-      <div class="navigation main-nav js-main-nav">
-        {% include nav.html %}
-      </div>
-      <footer role="contentinfo" class="site-footer"></footer>
-    </div>
-    <div class="main-content-wrap">
-      <div class="page-header">
-        <div class="main-content">
-          {% if site.search_enabled != nil %}
-          <div class="search js-search">
-            <div class="search-input-wrap">
-              <input type="text" class="js-search-input search-input" placeholder="Search" aria-label="Search" autocomplete="off">
-              <svg width="14" height="14" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" class="search-icon"><title>Search</title><g fill-rule="nonzero"><path d="M17.332 20.735c-5.537 0-10-4.6-10-10.247 0-5.646 4.463-10.247 10-10.247 5.536 0 10 4.601 10 10.247s-4.464 10.247-10 10.247zm0-4c3.3 0 6-2.783 6-6.247 0-3.463-2.7-6.247-6-6.247s-6 2.784-6 6.247c0 3.464 2.7 6.247 6 6.247z"/><path d="M11.672 13.791L.192 25.271 3.02 28.1 14.5 16.62z"/></g></svg>
-            </div>
-            <div class="js-search-results search-results-wrap"></div>
-          </div>
-          {% endif %}
-          {% if site.aux_links != nil %}
-            <ul class="list-style-none text-small mt-md-1 mb-md-1 pb-4 pb-md-0 js-aux-nav aux-nav">
-              {% for link in site.aux_links %}
-                <li class="d-inline-block my-0{% unless forloop.last %} mr-2{% endunless %}"><a href="{{ link.last }}" target="_blank">{{ link.first }}</a></li>
-              {% endfor %}
-            </ul>
-          {% endif %}
-        </div>
-      </div>
-      <div class="main-content" style="padding-bottom: 70px;">
-        {% unless page.url == "/" %}
-          {% if page.parent != nil and page.parent != page.title %}
-            <nav class="breadcrumb-nav">
-              <ol class="breadcrumb-nav-list">
-                <li class="breadcrumb-nav-list-item"><a href="{{ site.url }}{{ site.baseurl }}/{{ page.parent | slugify }}">{{ page.parent }}</a></li>
-                <li class="breadcrumb-nav-list-item"><span>{{ page.title }}</span></li>
-              </ol>
-            </nav>
-          {% endif %}
-        {% endunless %}
-        <div class="page-content">
-          {{ content }}
-
-          {% if page.has_children == true %}
-          <hr>
-          <h2 class="text-delta">Table of contents</h2>
-          {% assign children_list = site.pages | sort:"nav_order" %}
-          <ul>
-            {% for child in children_list %}
-              {% if child.parent == page.title and child.title != page.title %}
-              <li>
-                <a href="{{ child.url | absolute_url }}">{{ child.title }}</a>
-              </li>
-              {% endif %}
-            {% endfor %}
-          </ul>
-          {% endif %}
-        </div>
-      </div>
-    </div>
-  </div>
-</body>
-</html>
diff --git a/docs/assets/js/search-data.json b/docs/assets/js/search-data.json
deleted file mode 100644
index 50a4b9f..0000000
--- a/docs/assets/js/search-data.json
+++ /dev/null
@@ -1,12 +0,0 @@
----
----
-{
-  {% for page in site.html_pages %}"{{ forloop.index0 }}": {
-    "id": "{{ forloop.index0 }}",
-    "title": "{{ page.title | xml_escape }}",
-    "content": "{{ page.content | markdownify | strip_html | xml_escape | remove: 'Table of contents' | strip_newlines | replace: '\', ' ' }}",
-    "url": "{{ page.url | absolute_url | xml_escape }}",
-    "relUrl": "{{ page.url | xml_escape }}"
-  }{% if forloop.last %}{% else %},
-  {% endif %}{% endfor %}
-}
diff --git a/docs/docs/_dev_docs/gcp/image_creation.md b/docs/docs/_dev_docs/gcp/image_creation.md
deleted file mode 100644
index 57910f6..0000000
--- a/docs/docs/_dev_docs/gcp/image_creation.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Creating a Public Spotty Image
-
-1. Create an image.
-
-    Example of `spotty.yaml`:
-    ```yaml
-    # ...
-    
-    instances:
-      - name: gcp-image
-        provider: gcp
-        parameters:
-          zone: us-central1-a
-          machineType: n1-standard-1
-          onDemandInstance: true
-          imageName: spotty-image-[x-x-x]-[YYYYMMDD]
-          gpu:
-            type: nvidia-tesla-k80
-    ```
-   
-    The `imageName` parameter should contain a version of the image from the 
-    `spotty.providers.gcp.deployment.image_deployment.ImageDeployment.VERSION` property.
-
-    If new image is incompatible with the current version of Spotty, the family name should be
-    changed to `spotty_<version>`, where the `<version>` (written with dashes) is a version of Spotty 
-    since new image will be supported. The family name should also be changed in the code with the next release (
-   `spotty/providers/gcp/deployment/instance_deployment.py`, `_get_image()` method).
-
-    A command to create an image:
-    ```bash
-    spotty gcp create-image -f spotty-1-2-5
-    ```
-
-2. Share a created image:
-    
-    - Get the image policy:
-    
-        ```bash
-        IMAGE_NAME=spotty-1-0-0-20190827
-        gcloud compute images get-iam-policy $IMAGE_NAME --format json > policy.json
-        ```
-    
-    - Update the `policy.json` file by adding `bindings`:
-
-        ```json
-        {
-          "etag": "ACAB",
-          "version": 1,
-          "bindings": [
-            {
-              "members": [
-                "allAuthenticatedUsers"
-              ],
-              "role": "roles/compute.imageUser"
-            }
-          ]
-        }
-        ```
-    
-    - Set a new policy for the image:
-    
-        ```bash
-        gcloud compute images set-iam-policy $IMAGE_NAME policy.json
-        ```
-
-    Read more [here](https://cloud.google.com/compute/docs/access/granting-access-to-resources#sharing_specific_images_with_the_public).
diff --git a/docs/docs/aws/aws.md b/docs/docs/aws/aws.md
deleted file mode 100644
index 599ab43..0000000
--- a/docs/docs/aws/aws.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-layout: default
-title: AWS Provider
-has_children: true
-nav_order: 4
-permalink: /docs/aws-provider/
----
-
-# AWS Provider
diff --git a/docs/docs/aws/commands.md b/docs/docs/aws/commands.md
deleted file mode 100644
index eac9f58..0000000
--- a/docs/docs/aws/commands.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-layout: default
-title: Commands
-parent: AWS Provider
-nav_order: 2
-permalink: /docs/aws-provider/commands/
----
-
-# AWS Commands
-
-Commands that are specific for the AWS provider:
-
-  - `spotty aws create-ami [-h] [-d] [-c CONFIG] [--debug-mode] [INSTANCE_NAME]`
-
-    Creates an AMI with NVIDIA Docker. An AMI should be created only once for an AWS region.
-
-  - `spotty aws delete-ami [-h] [-d] [-c CONFIG] [INSTANCE_NAME]`
-
-    Deletes an AMI that was created using the `spotty aws create-ami` command.
-
-  - `spotty aws spot-prices [-h] [-d] -i INSTANCE_TYPE [-r REGION]`
-
-    Returns Spot Instance prices for a particular instance type across all AWS regions or within a specific region.
diff --git a/docs/docs/commands.md b/docs/docs/commands.md
deleted file mode 100644
index 37f130d..0000000
--- a/docs/docs/commands.md
+++ /dev/null
@@ -1,87 +0,0 @@
----
-layout: default
-title: Spotty Commands
-nav_order: 3
-permalink: /docs/commands/
----
-
-# Spotty Commands
-
-- __Start an instance__:
-
-  ```
-  spotty start  [-h] [-d] [-c CONFIG] [--dry-run] [INSTANCE_NAME]
-  ```
-
-  Runs an instance, synchronizes the project with that instance and starts the Docker container.
-
-  Use the `--dry-run` flag to display the steps that will be performed without actually running them.
-
-- __Stop an instance__:
-
-  ```
-  spotty stop [-h] [-d] [-c CONFIG] [INSTANCE_NAME]
-  ```
-
-  Terminates the running instance and applies deletion policies for the volumes.
-
-- __Connect to an instance__:
-
-  ```
-  spotty ssh [-h] [-d] [-c CONFIG] [-H] [-s SESSION_NAME] [-l]
-             [INSTANCE_NAME]
-  ```
-
-  Connects to the running Docker container or to the instance itself. Use the `-H` flag to connect to the host OS 
-  instead of the Docker container.
-
-- __Sync the project with an instance__:
-
-  ```
-  spotty sync [-h] [-d] [-c CONFIG] [--dry-run] [INSTANCE_NAME]
-  ```
-
-  Synchronizes the project with the running instance. It happens 
-  automatically the first time when you start an instance.
-
-- __Download files from an instance__:
-
-  ```
-  spotty download [-h] [-d] [-c CONFIG] -i PATTERN [--dry-run]
-                  [INSTANCE_NAME]
-   ```
-
-  Downloads files from the running instance. The `PATTERN` parameter has the same format and works in the same way as 
-  __Include Filters__ for the `aws s3 sync` command: see [Sync Filters]({{ site.baseurl }}/docs/configuration-file/#project-section).
-
-- __Run a custom script on an instance:__
-
-  ```
-  spotty run [-h] [-d] [-c CONFIG] [-s SESSION_NAME] [-S] [-l] [-r]
-             [-p PARAMETER=VALUE]
-             [INSTANCE_NAME] SCRIPT_NAME
-  ```
-
-  Runs a custom script inside the Docker container (see "scripts" section in 
-  [Configuration File]({{ site.baseurl }}/docs/configuration-file/#scripts-section-optional)).
-
-  Use the __`Ctrl + b`__, then __`x`__ combination of keys to kill tmux window with the process.
-
-  Use the __`Ctrl + b`__, then __`d`__ combination of keys to be detached from the SSH session. The script will keep running. 
-  Call the `spotty run` command again to be reattached to the running script. 
-  Read more about tmux here: [tmux Wiki](https://github.com/tmux/tmux/wiki){:target="_blank"}.
-
-  If you need to run the same script several times in parallel, use the `-s` parameter to
-  specify a different name for a tmux session.
-
-  To restart a running script, use the `-r` flag.
-
-  To synchronize the project with the instance before running a script, use the `-S` flag.
-  
-  Scripts can be parametrized. Parameters in a script are indicated by double braces. For example, 
-  {% raw %}`echo {{msg1}} {{msg2}}`{% endraw %}. Use the `-p` parameter to replace script parameters with real values: 
-  `-p msg1=deep -p msg2=learning`. If some script parameters were not provided, they will be replaced with
-  empty strings.
-
-All the commands have the `-c` parameter to specify a path to the configuration file. By default, Spotty is looking for 
-a `spotty.yaml` file in the current working directory.
diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md
deleted file mode 100644
index d38603c..0000000
--- a/docs/docs/configuration.md
+++ /dev/null
@@ -1,170 +0,0 @@
----
-layout: default
-title: Configuration File
-nav_order: 2
-permalink: /docs/configuration-file/
----
-
-# Configuration File
-
-By default, Spotty is looking for a `spotty.yaml` file in the root directory of the project. This file describes 
-parameters of a remote instance and an environment for the project. Here is a basic example of such file for AWS:
-
-```yaml
-project:
-  name: my-project-name
-  syncFilters:
-    - exclude:
-      - .git/*
-      - .idea/*
-      - '*/__pycache__/*'
-
-container:
-  projectDir: /workspace/project
-  image: tensorflow/tensorflow:latest-gpu-py3-jupyter
-  ports: [6006, 8888]
-  volumeMounts:
-    - name: workspace
-      mountPath: /workspace
-
-instances:
-  - name: i1
-    provider: aws
-    parameters:
-      region: eu-west-1
-      instanceType: p2.xlarge
-      volumes:
-        - name: workspace
-          parameters:
-            size: 10
-```
-
-For GCP, `project` and `container` configurations are exactly the same, but an instance 
-configuration is slightly different, you can find the specification 
-[here]({{ site.baseurl }}/docs/gcp-provider/instance-parameters/).
-
-## Available Parameters
-
-Configuration file consists of 4 sections: `project`, `container`, `instances` and `scripts`.
-
-### __`project`__ section:
-
-- __`name`__ - the name of your project. It will be used to create an S3 bucket and a CloudFormation stack to run 
-an instance.
-
-- __`syncFilters`__ _(optional)_ - filters to skip some directories or files during synchronization. By default, all 
-project files will be synced with the instance. Example:
-    ```yaml
-    syncFilters:
-      - exclude:
-          - .idea/*
-          - .git/*
-          - data/*
-      - include:
-          - data/test/*
-      - exclude:
-          - data/test/dump.json
-    ```
-    
-    It will skip ".idea/", ".git/" and "data/" directories except the "data/test/" directory. All files from 
-    the "data/test/" directory will be synced with the instance except the "data/test/dump.json" file.
-    
-    You can read more about filters 
-    here: [Use of Exclude and Include Filter](https://docs.aws.amazon.com/cli/latest/reference/s3/index.html#use-of-exclude-and-include-filters){:target="_blank"}. 
-
-### __`container`__ section:
-
-- __`projectDir`__ - a directory inside the container where the local project will be copied. If
-it's a subdirectory of a container volume, the project will be located on that volume, 
-otherwise, the data will be lost once the instance is terminated.
-
-- __`image`__ _(optional)_ - the name of the Docker image that contains the environment for your project. For example, 
-you could use [TensorFlow image for GPU](https://hub.docker.com/r/tensorflow/tensorflow/){:target="_blank"} 
-(`tensorflow/tensorflow:latest-gpu-py3-jupyter`). It already contains NumPy, SciPy, scikit-learn, pandas, Jupyter Notebook and 
-TensorFlow itself. If you need to use your own image, you can specify the path to your Dockerfile in the 
-__`file`__ parameter (see below), or push your image to the [Docker Hub](https://hub.docker.com/){:target="_blank"}.
-
-- __`file`__ _(optional)_ - relative path to your custom Dockerfile.
-    
-    __Note:__ make sure that the build context for the Dockerfile doesn't contain gigabytes of training data or 
-    some other heavy data (keep the Dockerfile in a separate directory or use the `.dockerignore` file). Otherwise, you would get an out-of-space error, because Docker copies the entire build
-    context to the Docker daemon during the build. Read more here: ["docker build" command](https://docs.docker.com/engine/reference/commandline/build/){:target="_blank"}.
-
-    __Example:__ if you use TensorFlow and need to download your dataset from S3, you could install 
-    [AWS CLI](https://github.com/aws/aws-cli){:target="_blank"} on top of the original TensorFlow image. Just create the 
-    `Dockerfile` file in the `docker/` directory of your project:
-    ```dockerfile
-    FROM tensorflow/tensorflow:latest-gpu-py3-jupyter
-    
-    RUN pip install --upgrade awscli
-    ```
-
-    Then set the `file` parameter to the `docker/Dockerfile` value.
-
-- __`volumeMounts`__ _(optional)_ - where to mount instance volumes into the container's filesystem. Each element 
-of a list has the following parameters:
-    - __`name`__ - this must match the name of an instance volume.
-    - __`mountPath`__ - a path within the container at which the volume should be mounted.
-
-- __`workingDir`__ _(optional)_ - working directory for your custom scripts (see "scripts" section below),
-
-- __`commands`__ _(optional)_ - commands which should be performed once your container is started. For example, you 
-could download your datasets from an S3 bucket to the project directory (see "project" section):
-    ```yaml
-    commands: |
-      aws s3 sync s3://my-bucket/datasets/my-dataset /workspace/project/data
-    ```
-
-- __`ports`__ _(optional)_ - list of ports to open. For example:
-    ```yaml
-    ports: [6006, 8888]
-    ```
-    It will open ports 6006 for Jupyter Notebook and 8008 for TensorBoard. 
-
-- __`runtimeParameters`__ _(optional)_ - a list of additional parameters for the container runtime. For example:
-    ```yaml
-    runtimeParameters: ['--privileged', '--shm-size', '2G']
-    ```
-
-### __`instances`__ section:
-
-This section contains a list of instances. Each instance is described with the following parameters:
-
-- __`name`__ - a name of the instance. Use this name to manage the instance with the commands like 
-"spotty start" or "spotty stop". Also Spotty uses this name in the names of AWS<!-- and GCP--> resources.
-
-- __`provider`__ - a provider for the instance. At the moment Spotty supports "__aws__" (Amazon Web Services).
-and "__gcp__" (Google Cloud Platform) providers.
-
-- __`parameters`__ - parameters of the instance. These parameters are different for different providers:
-    - [AWS Instance Parameters]({{ site.baseurl }}/docs/aws-provider/instance-parameters/)
-    - [GCP instance parameters]({{ site.baseurl }}/docs/gcp-provider/instance-parameters/)
-
-### __`scripts`__ section _(optional)_:
-
-This section contains customs scripts which can be run using the `spotty run <SCRIPT_NAME>`
-command. The following example defines scripts `train`, `jupyter` and `tensorflow`:
-
-{% raw %}         
-```yaml
-train: |
-  PYTHONPATH=/workspace/project
-  python /workspace/project/model/train.py --model-name {{MODEL}}
-jupyter: |
-  jupyter notebook --allow-root --notebook-dir=/workspace/project
-tensorboard: |
-  tensorboard --logdir /workspace/outputs
-```
-{% endraw %}
-
-For example, training can be started using the following command:
-
-```bash
-spotty run train -p MODEL=my-model
-```
-
-Use the __`Ctrl + b`__, then __`d`__ combination of keys to be detached from the SSH session - 
-the script will keep running. Then use the same command again to be reattached to the running script.
-
-__Note:__ don't forget to use the "|" character for multi-line scripts, otherwise the YAML parser
-will merge multiple lines together.
diff --git a/docs/docs/gcp/commands.md b/docs/docs/gcp/commands.md
deleted file mode 100644
index 2115b81..0000000
--- a/docs/docs/gcp/commands.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-layout: default
-title: Commands
-parent: GCP Provider (beta)
-nav_order: 3
-permalink: /docs/gcp-provider/commands/
----
-
-# GCP Commands
-
-Commands that are specific for the GCP provider:
-
-  - `spotty gcp create-image [-h] [-d] [-c CONFIG] [-f FAMILY_NAME] [--debug-mode] [INSTANCE_NAME]`
-
-    Creates an image with NVIDIA Docker.
-
-  - `spotty gcp delete-image [-h] [-d] [-c CONFIG] [INSTANCE_NAME]`
-
-    Deletes an image that was created using the `spotty gcp create-image` command.
diff --git a/docs/docs/gcp/gcp.md b/docs/docs/gcp/gcp.md
deleted file mode 100644
index fa4c51e..0000000
--- a/docs/docs/gcp/gcp.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-layout: default
-title: GCP Provider (beta)
-has_children: true
-nav_order: 5
-permalink: /docs/gcp-provider/
----
-
-# GCP Provider _(beta)_
diff --git a/docs/docs/gcp/volumes.md b/docs/docs/gcp/volumes.md
deleted file mode 100644
index 6407c35..0000000
--- a/docs/docs/gcp/volumes.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-layout: default
-title: Disks and Deletion Policies
-parent: GCP Provider (beta)
-nav_order: 4
-permalink: /docs/gcp-provider/disks-and-deletion-policies/
----
-
-# Disks and Deletion Policies
-
-By default, disks have names in the following format: `<PROJECT_NAME>-<INSTANCE_NAME>-<VOLUME_NAME>`.
-But you can specify a custom name using the `diskName` parameter. 
-
-When you're starting an instance:
-1. Spotty is looking for existing disks using their names. If a disk exists, it will be attached to the 
-instance.
-2. If not - Spotty will be looking for a snapshot with the same name. If the snapshot exists, the disk will be 
-restored from that snapshot.
-3. If neither snapshot nor disk with this name exists, a new disk will be created. 
-
-### __(!) Note:__ Deletion Policies are not implemented yet, so your disks will always retain. Below you can see how it's supposed to work.
-
-When you're stopping the instance Spotty applies deletion policies for the disks. There are 4 deletion policies that 
-can be specified using the `deletionPolicy` parameter:
-
-- __create_snapshot__: this is the default deletion policy. Spotty will create a new snapshot every time you're 
-stopping an instance, the old snapshot will be renamed. GCP uses incremental snapshots, so each new snapshot keeps 
-only the data that was changed since the last snapshot made (see: 
-[Creating persistent disk snapshots](https://cloud.google.com/compute/docs/disks/create-snapshots){:target="_blank"}).
-
-- __update_snapshot__: a new snapshot will be created and the old one will be deleted.
-
-- __retain__: the disk won't be deleted and a snapshot won't be created.
-
-- __delete__: the disk will be deleted without creating a snapshot. All data on this disk will be lost.
diff --git a/docs/index.md b/docs/index.md
deleted file mode 100644
index 4bf5917..0000000
--- a/docs/index.md
+++ /dev/null
@@ -1,74 +0,0 @@
----
-layout: default
-title: Getting Started
-nav_order: 1
-permalink: /
----
-
-
-# An Open-source Tool for Training<br />Deep Learning Models in the Cloud
-{: .fs-9 }
-
-Spotty makes training of deep learning models on 
-[AWS Spot Instances](https://aws.amazon.com/ec2/spot/){:target="_blank"}
-and on [GCP Preemtible VMs](https://cloud.google.com/preemptible-vms/){:target="_blank"}
-as simple as training on your local machine.
-{: .fs-6 .fw-300 }
-
-[Get started now](#getting-started){: .btn .btn-purple .fs-5 .mb-4 .mb-md-0 .mr-2 } 
-[View it on GitHub](https://github.com/apls777/spotty){:target="_blank"}{: .btn .fs-5 }
-
----
-
-
-## Getting Started
-
-### __Installation__
-
-Requirements:
-  * Python >=3.5
-  * AWS CLI (see [Installing the AWS Command Line Interface](http://docs.aws.amazon.com/cli/latest/userguide/installing.html){:target="_blank"})
-  if you're using AWS
-  * Google Cloud SDK (see [Installing Google Cloud SDK](https://cloud.google.com/sdk/install){:target="_blank"}) 
-  if you're using GCP
-
-Use [pip](http://www.pip-installer.org/en/latest/){:target="_blank"} to install or upgrade Spotty:
-
-```bash
-pip install -U spotty
-```
-
-### __Prepare a configuration file__
-
-Prepare a `spotty.yaml` file and put it to the root directory of your project:
-
-   - See the file specification [here]({{ site.baseurl }}/docs/configuration-file/).
-   - Read [this](https://medium.com/@apls/how-to-train-deep-learning-models-on-aws-spot-instances-using-spotty-8d9e0543d365){:target="_blank"} 
-   article for a real-world example.
-
-### __Start an instance__
-
-Use the following command to launch an instance with the Docker container:
-    
-```bash
-spotty start
-```
-
-It will start a Spot instance, restore snapshots if any, synchronize the project with the running instance 
-and start the Docker container with the project environment.
-
-### __Train your models or run notebooks__
-
-To connect to the running container via SSH, use the following command:
-
-```bash
-spotty ssh
-```
-
-It runs a [tmux](https://github.com/tmux/tmux/wiki){:target="_blank"} session, so you can always detach this session using
-__`Ctrl + b`__, then __`d`__ combination of keys. To be attached to that session later, just use the
-`spotty ssh` command again.
-
-Also, you can run custom scripts inside the Docker container using the `spotty run <SCRIPT_NAME>` command. Read more
-about custom scripts in the documentation: 
-[Configuration File: "scripts" section]({{ site.baseurl }}/docs/configuration-file/#scripts-section-optional).
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..cb659df
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,36 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=python -msphinx
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+set SPHINXPROJ=spotty
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The Sphinx module was not found. Make sure you have Sphinx installed,
+	echo.then set the SPHINXBUILD environment variable to point to the full
+	echo.path of the 'sphinx-build' executable. Alternatively you may add the
+	echo.Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.http://sphinx-doc.org/
+	exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..cdb3bd4
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,4 @@
+sphinx==3.1.2
+recommonmark==0.6.0
+sphinx-argparse==0.2.5
+sphinx-rtd-theme==0.5.0
diff --git a/docs/android-chrome-192x192.png b/docs/source/_static/favicon/android-chrome-192x192.png
similarity index 100%
rename from docs/android-chrome-192x192.png
rename to docs/source/_static/favicon/android-chrome-192x192.png
diff --git a/docs/android-chrome-512x512.png b/docs/source/_static/favicon/android-chrome-512x512.png
similarity index 100%
rename from docs/android-chrome-512x512.png
rename to docs/source/_static/favicon/android-chrome-512x512.png
diff --git a/docs/apple-touch-icon.png b/docs/source/_static/favicon/apple-touch-icon.png
similarity index 100%
rename from docs/apple-touch-icon.png
rename to docs/source/_static/favicon/apple-touch-icon.png
diff --git a/docs/browserconfig.xml b/docs/source/_static/favicon/browserconfig.xml
similarity index 100%
rename from docs/browserconfig.xml
rename to docs/source/_static/favicon/browserconfig.xml
diff --git a/docs/favicon-16x16.png b/docs/source/_static/favicon/favicon-16x16.png
similarity index 100%
rename from docs/favicon-16x16.png
rename to docs/source/_static/favicon/favicon-16x16.png
diff --git a/docs/favicon-32x32.png b/docs/source/_static/favicon/favicon-32x32.png
similarity index 100%
rename from docs/favicon-32x32.png
rename to docs/source/_static/favicon/favicon-32x32.png
diff --git a/docs/favicon.ico b/docs/source/_static/favicon/favicon.ico
similarity index 100%
rename from docs/favicon.ico
rename to docs/source/_static/favicon/favicon.ico
diff --git a/docs/mstile-150x150.png b/docs/source/_static/favicon/mstile-150x150.png
similarity index 100%
rename from docs/mstile-150x150.png
rename to docs/source/_static/favicon/mstile-150x150.png
diff --git a/docs/safari-pinned-tab.svg b/docs/source/_static/favicon/safari-pinned-tab.svg
similarity index 100%
rename from docs/safari-pinned-tab.svg
rename to docs/source/_static/favicon/safari-pinned-tab.svg
diff --git a/docs/site.webmanifest b/docs/source/_static/favicon/site.webmanifest
similarity index 100%
rename from docs/site.webmanifest
rename to docs/source/_static/favicon/site.webmanifest
diff --git a/docs/assets/images/logo_2200x700.png b/docs/source/_static/images/logo_2200x700.png
similarity index 100%
rename from docs/assets/images/logo_2200x700.png
rename to docs/source/_static/images/logo_2200x700.png
diff --git a/docs/assets/images/logo_400x130.png b/docs/source/_static/images/logo_400x130.png
similarity index 100%
rename from docs/assets/images/logo_400x130.png
rename to docs/source/_static/images/logo_400x130.png
diff --git a/docs/source/_static/images/logo_400x130_grey.png b/docs/source/_static/images/logo_400x130_grey.png
new file mode 100644
index 0000000..23b4b9e
Binary files /dev/null and b/docs/source/_static/images/logo_400x130_grey.png differ
diff --git a/docs/source/_static/images/logo_400x130_white.png b/docs/source/_static/images/logo_400x130_white.png
new file mode 100644
index 0000000..8d09801
Binary files /dev/null and b/docs/source/_static/images/logo_400x130_white.png differ
diff --git a/docs/assets/images/logo_740x240.png b/docs/source/_static/images/logo_740x240.png
similarity index 100%
rename from docs/assets/images/logo_740x240.png
rename to docs/source/_static/images/logo_740x240.png
diff --git a/docs/source/_static/scripts.js b/docs/source/_static/scripts.js
new file mode 100644
index 0000000..c89ab59
--- /dev/null
+++ b/docs/source/_static/scripts.js
@@ -0,0 +1,7 @@
+window.onload = function() {
+    var links = document.querySelectorAll('a.external');
+
+    for(var i = 0; i < links.length; i++) {
+       links[i].target = '_blank';
+    }
+}
diff --git a/docs/source/_static/styles.css b/docs/source/_static/styles.css
new file mode 100644
index 0000000..b4f2afa
--- /dev/null
+++ b/docs/source/_static/styles.css
@@ -0,0 +1,23 @@
+.wy-nav-content {
+    max-width: 1280px;
+}
+
+.wy-side-nav-search {
+    background: none;
+}
+
+.wy-menu-vertical header, .wy-menu-vertical p.caption {
+    color: #e44859;
+}
+
+.wy-side-nav-search > a img.logo, .wy-side-nav-search .wy-dropdown > a img.logo {
+    max-width: 75%;
+}
+
+.wy-breadcrumbs a.icon-home {
+    color: #e44859;
+}
+
+.section#welcome-to-spotty-documentation {
+    display: none;
+}
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 0000000..baae6f3
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# spotty documentation build configuration file, created by
+# sphinx-quickstart on Fri Jul 17 16:00:08 2020.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+# import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+
+import os
+import sys
+
+
+sys.path.insert(0, os.path.abspath('../..'))
+
+import spotty
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+    'recommonmark',
+    'sphinxarg.ext',
+    'sphinx_rtd_theme',
+    'sphinx.ext.autosectionlabel',
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = {
+    '.rst': 'restructuredtext',
+    '.md': 'markdown',
+}
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = 'spotty'
+copyright = '2020, Oleg Polosin'
+author = 'Oleg Polosin'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = spotty.__version__
+# The full version, including alpha/beta/rc tags.
+release = spotty.__version__
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = None
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This patterns also effect to html_static_path and html_extra_path
+exclude_patterns = []
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = False
+
+# to display double-dash (--) in epilogs of some Spotty commands
+smartquotes = False
+
+# Prefix document path to section labels, otherwise autogenerated labels would look like 'heading'
+# rather than 'path/to/file:heading'
+autosectionlabel_prefix_document = True
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'sphinx_rtd_theme'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+#
+html_theme_options = {
+    'logo_only': True,
+    'style_external_links': True,
+    'collapse_navigation': False,
+    'titles_only': True,
+}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# Custom sidebar templates, must be a dictionary that maps document names
+# to template names.
+#
+# This is required for the alabaster theme
+# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
+html_sidebars = {
+    '**': [
+        'about.html',
+        'navigation.html',
+        'relations.html',  # needs 'show_related': True theme option to display
+        'searchbox.html',
+        'donate.html',
+    ]
+}
+
+html_show_copyright = False
+html_show_sphinx = False
+html_show_sourcelink = False
+
+html_logo = '_static/images/logo_400x130_grey.png'
+html_favicon = '_static/favicon/favicon.ico'
+
+html_css_files = [
+    'styles.css',
+]
+
+html_js_files = [
+    'scripts.js',
+]
+
+# -- Options for HTMLHelp output ------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'spottydoc'
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+    # The paper size ('letterpaper' or 'a4paper').
+    #
+    # 'papersize': 'letterpaper',
+
+    # The font size ('10pt', '11pt' or '12pt').
+    #
+    # 'pointsize': '10pt',
+
+    # Additional stuff for the LaTeX preamble.
+    #
+    # 'preamble': '',
+
+    # Latex figure (float) alignment
+    #
+    # 'figure_align': 'htbp',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+#  author, documentclass [howto, manual, or own class]).
+latex_documents = [
+    (master_doc, 'spotty.tex', 'spotty Documentation',
+     'Oleg Polosin', 'manual'),
+]
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    (master_doc, 'spotty', 'spotty Documentation',
+     [author], 1)
+]
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+    (master_doc, 'spotty', 'spotty Documentation',
+     author, 'spotty', 'One line description of project.',
+     'Miscellaneous'),
+]
diff --git a/docs/source/docs/cli/spotty-aws.rst b/docs/source/docs/cli/spotty-aws.rst
new file mode 100644
index 0000000..7ceaf5f
--- /dev/null
+++ b/docs/source/docs/cli/spotty-aws.rst
@@ -0,0 +1,8 @@
+spotty aws
+==========
+
+.. argparse::
+   :nodefaultconst:
+   :ref: spotty.cli.get_parser
+   :prog: spotty
+   :path: aws
diff --git a/docs/source/docs/cli/spotty-download.rst b/docs/source/docs/cli/spotty-download.rst
new file mode 100644
index 0000000..4fe524d
--- /dev/null
+++ b/docs/source/docs/cli/spotty-download.rst
@@ -0,0 +1,8 @@
+spotty download
+===============
+
+.. argparse::
+   :nodefaultconst:
+   :ref: spotty.cli.get_parser
+   :prog: spotty
+   :path: download
diff --git a/docs/source/docs/cli/spotty-exec.rst b/docs/source/docs/cli/spotty-exec.rst
new file mode 100644
index 0000000..e010aa3
--- /dev/null
+++ b/docs/source/docs/cli/spotty-exec.rst
@@ -0,0 +1,8 @@
+spotty exec
+===========
+
+.. argparse::
+   :nodefaultconst:
+   :ref: spotty.cli.get_parser
+   :prog: spotty
+   :path: exec
diff --git a/docs/source/docs/cli/spotty-run.rst b/docs/source/docs/cli/spotty-run.rst
new file mode 100644
index 0000000..eda0675
--- /dev/null
+++ b/docs/source/docs/cli/spotty-run.rst
@@ -0,0 +1,8 @@
+spotty run
+==========
+
+.. argparse::
+   :nodefaultconst:
+   :ref: spotty.cli.get_parser
+   :prog: spotty
+   :path: run
diff --git a/docs/source/docs/cli/spotty-sh.rst b/docs/source/docs/cli/spotty-sh.rst
new file mode 100644
index 0000000..1edd8b7
--- /dev/null
+++ b/docs/source/docs/cli/spotty-sh.rst
@@ -0,0 +1,8 @@
+spotty sh
+=========
+
+.. argparse::
+   :nodefaultconst:
+   :ref: spotty.cli.get_parser
+   :prog: spotty
+   :path: sh
diff --git a/docs/source/docs/cli/spotty-start.rst b/docs/source/docs/cli/spotty-start.rst
new file mode 100644
index 0000000..6dd1cf7
--- /dev/null
+++ b/docs/source/docs/cli/spotty-start.rst
@@ -0,0 +1,8 @@
+spotty start
+============
+
+.. argparse::
+   :nodefaultconst:
+   :ref: spotty.cli.get_parser
+   :prog: spotty
+   :path: start
diff --git a/docs/source/docs/cli/spotty-stop.rst b/docs/source/docs/cli/spotty-stop.rst
new file mode 100644
index 0000000..a0d266f
--- /dev/null
+++ b/docs/source/docs/cli/spotty-stop.rst
@@ -0,0 +1,8 @@
+spotty stop
+===========
+
+.. argparse::
+   :nodefaultconst:
+   :ref: spotty.cli.get_parser
+   :prog: spotty
+   :path: stop
diff --git a/docs/source/docs/cli/spotty-sync.rst b/docs/source/docs/cli/spotty-sync.rst
new file mode 100644
index 0000000..03d2fd0
--- /dev/null
+++ b/docs/source/docs/cli/spotty-sync.rst
@@ -0,0 +1,8 @@
+spotty sync
+===========
+
+.. argparse::
+   :nodefaultconst:
+   :ref: spotty.cli.get_parser
+   :prog: spotty
+   :path: sync
diff --git a/docs/source/docs/cli/spotty.rst b/docs/source/docs/cli/spotty.rst
new file mode 100644
index 0000000..79d6f2a
--- /dev/null
+++ b/docs/source/docs/cli/spotty.rst
@@ -0,0 +1,28 @@
+Spotty Command-line Interface
+=============================
+
+.. argparse::
+   :nosubcommands:
+   :nodefaultconst:
+   :noepilog:
+   :nodescription:
+   :ref: spotty.cli.get_parser
+   :prog: spotty
+
+.. toctree::
+   :maxdepth: 1
+   :caption: Sub-commands
+
+   spotty-start
+   spotty-stop
+   spotty-sh
+   spotty-sync
+   spotty-download
+   spotty-run
+   spotty-exec
+
+.. toctree::
+   :maxdepth: 1
+   :caption: Custom provider sub-commands
+
+   spotty-aws
diff --git a/docs/source/docs/providers/aws/caching-docker-image-on-an-ebs-volume.md b/docs/source/docs/providers/aws/caching-docker-image-on-an-ebs-volume.md
new file mode 100644
index 0000000..0e82d44
--- /dev/null
+++ b/docs/source/docs/providers/aws/caching-docker-image-on-an-ebs-volume.md
@@ -0,0 +1,29 @@
+# Caching Docker Image on an EBS Volume
+
+You can cache images that you've built or downloaded from the internet on an EBS volume or in a snapshot.
+
+A configuration file has the "__dockerDataRoot__" parameter. It's a directory on the host OS where the Docker 
+daemon will save all the images.
+
+Specify the `moundDir` directory for one of the instance volumes and set the `dockerDataRoot` parameter
+to the same value (or to a subdirectory of the `moundDir` directory). Also, consider changing a deletion policy
+for that volume to "__retain__", then the volume with the cache will be retained and the next time it just will be 
+attached to the instance.
+
+Example:
+```yaml
+# ...
+
+instances:
+  - name: aws-1
+    provider: aws
+    parameters:
+      # ...
+      dockerDataRoot: /docker
+      volumes:
+        # ...
+        - name: docker
+          parameters:
+            size: 10
+            mountDir: /docker
+```
diff --git a/docs/docs/aws/volumes.md b/docs/source/docs/providers/aws/ebs-volumes-and-deletion-policies.md
similarity index 51%
rename from docs/docs/aws/volumes.md
rename to docs/source/docs/providers/aws/ebs-volumes-and-deletion-policies.md
index 268b158..44a62ef 100644
--- a/docs/docs/aws/volumes.md
+++ b/docs/source/docs/providers/aws/ebs-volumes-and-deletion-policies.md
@@ -1,12 +1,4 @@
----
-layout: default
-title: Volumes and Deletion Policies
-parent: AWS Provider
-nav_order: 3
-permalink: /docs/aws-provider/volumes-and-deletion-policies/
----
-
-# Volumes and Deletion Policies
+# EBS Volumes and Deletion Policies
 
 By default, EBS volumes have names in the following format: `<PROJECT_NAME>-<INSTANCE_NAME>-<VOLUME_NAME>`.
 But you can specify a custom name using the `volumeName` parameter. 
@@ -21,13 +13,13 @@ restored from that snapshot.
 When you're stopping the instance Spotty applies deletion policies for the volumes. There are 4 deletion policies that 
 can be specified using the `deletionPolicy` parameter:
 
-- __create_snapshot__: this is the default deletion policy. Spotty will create a new snapshot every time you're 
-stopping an instance, the old snapshot will be renamed. AWS uses incremental snapshots, so each new snapshot keeps 
-only the data that was changed since the last snapshot made (see: 
-[How Incremental Snapshots Work](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSSnapshots.html#how_snapshots_work){:target="_blank"}).
+- __Retain__: this is the default deletion policy. The volume will retain, a snapshot won't be created.
 
-- __update_snapshot__: a new snapshot will be created and the old one will be deleted.
+- __CreateSnapshot__: Spotty will create a new snapshot every time you're stopping an instance, the old snapshot 
+will be renamed. AWS uses incremental snapshots, so each new snapshot keeps only the data that was changed since 
+the last snapshot made (see: 
+[How Incremental Snapshots Work](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSSnapshots.html#how_snapshots_work)).
 
-- __retain__: the volume will not be deleted and a snapshot will not be created.
+- __UpdateSnapshot__: a new snapshot will be created and the old one will be deleted.
 
-- __delete__: the volume will be deleted without creating a snapshot. All data on this volume will be lost.
+- __Delete__: the volume will be deleted without creating a snapshot. All data on this volume will be lost.
diff --git a/docs/docs/faq.md b/docs/source/docs/providers/aws/faq.md
similarity index 50%
rename from docs/docs/faq.md
rename to docs/source/docs/providers/aws/faq.md
index d3a0384..67993e9 100644
--- a/docs/docs/faq.md
+++ b/docs/source/docs/providers/aws/faq.md
@@ -1,47 +1,4 @@
----
-layout: default
-title: FAQ
-nav_order: 6
-permalink: /docs/faq/
----
-
-## How to cache a Docker image?
-
-You can cache images that you've built or downloaded from the internet on an EBS volume or in a snapshot.
-
-A configuration file has the "__dockerDataRoot__" parameter. It's a directory on the host OS where the Docker 
-daemon will save all the images.
-
-Specify the `moundDir` directory for one of the instance volumes and set the `dockerDataRoot` parameter
-to the same value (or to a subdirectory of the `moundDir` directory). Also, consider changing a deletion policy
-for that volume to "__retain__", then the volume with the cache will be retained and the next time it just will be 
-attached to the instance.
-
-Example:
-```yaml
-# ...
-
-instances:
-  - name: i1
-    provider: aws
-    parameters:
-      # ...
-      dockerDataRoot: /docker
-      volumes:
-        # ...
-        - name: docker
-          parameters:
-            size: 10
-            mountDir: /docker
-            deletionPolicy: retain
-```
-
-
-## How to connect to the running container from the host OS?
-
-Use the "__container__" command. This alias is available for the "ubuntu" and "root" users. You might want
-to use this alias when you're creating a new tmux window using the __`Ctrl + b`__, then __`c`__ combination of keys.
-
+# FAQ
 
 ## How does Spotty choose the AWS Availability Zone where to run the instance?
 
@@ -58,16 +15,21 @@ or AZs of the volumes are different.
 
 Most likely the instance cannot be launched because you're trying to launch a Spot instance
 and it cannot be fulfilled. You can try to change the region or availability zone, choose another
-type of the instance, or run an On-demand Instance by setting the `onDemandInstance` parameter to `true`.
+type of the instance, or run an On-demand Instance by removing the `spotInstance` parameter or setting it to `false`.
 
 
 ## The instance is failed to start. Where can I find the logs?
 
-1. If the CloudFormation stack is failed to start during the container creation, then the instance is launched, 
-but the container is failed to start. So you can connect to the host OS using the following command:
+1. If the CloudFormation stack failed when it was launching the instance itself, then you need to log in to
+your AWS Console and check CloudFormation logs there.
+
+2. If the stack is failed after the instance is launched, then most likely the container is
+failed to start because of the startup commands. In this case, Spotty usually automatically downloads necessary 
+logs to your local machine and shows where to find them. If that didn't happen, you can connect to the 
+host OS using the following command:
 
     ```bash
-    spotty ssh -H
+    spotty sh -H
     ```
     
     Then you can check the `cfn-init` logs to find out why the container is failed:
@@ -75,14 +37,11 @@ but the container is failed to start. So you can connect to the host OS using th
     sudo tail /var/log/cfn-init-cmd.log
     ```
 
-2. If the stack is failed when launching an instance, then check the CloudFormation logs in AWS Console.
-
-
 ## How to ssh to a Spotty instance from a different machine?
 
 When you start an instance, Spotty creates an EC2 Key Pair and downloads a private key to the 
 `~/.spotty/keys/aws` directory. If you want to have access to the instance from a different machine using 
-the `spotty ssh` or the `spotty run` commands, you need to copy the private key to that machine to the same directory.
+the `spotty sh` or the `spotty run` commands, you need to copy the private key to that machine to the same directory.
 
 __Note:__ if you already have an EC2 Key Pair created for the project and the private key was 
 saved on the machine A (where from an instance was launched the first time) and then you're running
diff --git a/docs/docs/aws/instance_parameters.md b/docs/source/docs/providers/aws/instance-parameters.md
similarity index 63%
rename from docs/docs/aws/instance_parameters.md
rename to docs/source/docs/providers/aws/instance-parameters.md
index 881c2cd..7dd93d2 100644
--- a/docs/docs/aws/instance_parameters.md
+++ b/docs/source/docs/providers/aws/instance-parameters.md
@@ -1,15 +1,10 @@
----
-layout: default
-title: Instance Parameters
-parent: AWS Provider
-nav_order: 1
-permalink: /docs/aws-provider/instance-parameters/
----
+# Instance Parameters
 
-# AWS Instance Parameters
+Instance parameters are part of the [configuration file], but for each provider they are different. 
+Here you can find parameters for an AWS instance:
 
-Instance parameters are part of the [configuration file]({{ site.baseurl }}/docs/configuration-file/), but 
-for each provider they are different. Here you can find parameters for an AWS instance:
+- __`containerName`__ _(optional)_ - a name of the container from the `containers` section.
+Default value: `default`.
 
 - __`region`__ - AWS region where to run an instance (you can use command `spotty aws spot-prices` to find the 
 cheapest region).
@@ -22,10 +17,9 @@ set as well. If it's not specified, a default subnet will be used.
 
 - __`instanceType`__ - a type of the instance to run. You can find more information about 
 types of GPU instances here: 
-[Recommended GPU Instances](https://docs.aws.amazon.com/dlami/latest/devguide/gpu.html){:target="_blank"}.
+[Recommended GPU Instances](https://docs.aws.amazon.com/dlami/latest/devguide/gpu.html).
 
-- __`onDemandInstance`__ _(optional)_ - run On-demand instance instead of a Spot instance. Available values: "true", 
-"false" (default value is "false").
+- __`spotInstance`__ _(optional)_ - if set to `true`, runs a Spot instance instead of an On-demand instance,
 
 - __`amiName`__ _(optional)_ - a name of the AMI with NVIDIA Docker (default value is "SpottyAMI"). Use the 
 `spotty aws create-ami` command to create it. This AMI will be used to run your application inside the Docker container.
@@ -35,13 +29,13 @@ shared Spotty AMI.
 
 - __`maxPrice`__ _(optional)_ - the maximum price per hour that you are willing to pay for a Spot Instance. By default, 
 it's the On-demand price for the chosen instance type. Read more here: 
-[Spot Instances](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-spot-instances.html){:target="_blank"}.
+[Spot Instances](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-spot-instances.html).
 
 - __`rootVolumeSize`__ _(optional)_ - size of the root volume in GB. The root volume will be destroyed once 
 the instance is terminated. Use attached volumes to store the data you need to keep (see "volumes" parameter below).
 
 - __`dockerDataRoot`__ _(optional)_ - directory where Docker will store all downloaded and built images. 
-Read more: [How to cache a Docker image]({{ site.baseurl }}/docs/faq/#how-to-cache-a-docker-image).
+Read more: [Caching Docker Image on an EBS Volume].
 
 - __`volumes`__ _(optional)_ - the list of volumes to attach to the instance:
     - __`name`__ - a name of the volume. This name should match one of the container's `volumeMounts` to have this 
@@ -50,15 +44,14 @@ Read more: [How to cache a Docker image]({{ site.baseurl }}/docs/faq/#how-to-cac
     - __`parameters`__ _(optional)_ - parameters of the volume:
         - __`type`__ _(optional)_ - the volume type. Supported types: "__gp2__", "__sc1__", "__st1__" 
         and "__standard__". The default value is "gp2". Read more here: 
-        [Amazon EBS Volume Types](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSVolumeTypes.html){:target="_blank"}.
+        [Amazon EBS Volume Types](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSVolumeTypes.html).
     
         - __`size`__ _(optional)_ - size of the volume in GB. Size of the volume cannot be less than the size of 
         the existing snapshot but can be increased.
 
         - __`deletionPolicy`__ _(optional)_ - what to do with the volume once the instance is terminated using the 
-        `spotty stop` command. Possible values include: "__create_snapshot__" _(value by default)_, "__update_snapshot__", 
-        "__retain__" and  "__delete__". Read more here: 
-        [Volumes and Deletion Policies]({{ site.baseurl }}/docs/aws-provider/volumes-and-deletion-policies/).
+        `spotty stop` command. Possible values include: "__Retain__" _(value by default)_, "__CreateSnapshot__", 
+        "__UpdateSnapshot__" and  "__Delete__". Read more here: [EBS Volumes and Deletion Policies].
 
         - __`volumeName`__ _(optional)_ - name of the EBS volume. The default name is 
         "{project_name}-{instance_name}-{volume_name}".
@@ -66,19 +59,33 @@ Read more: [How to cache a Docker image]({{ site.baseurl }}/docs/faq/#how-to-cac
         - __`mountDir`__ _(optional)_ - directory where the volume will be mounted on the instance. The default 
         directory is "/mnt/{ebs_volume_name}".
 
-- __`localSshPort`__ _(optional)_ - if the local SSH port is specified, the commands `spotty ssh`, `spotty run` 
-and `spotty sync` will do SSH connections to the instance using the IP address __127.0.0.1__ and the specified port. 
-It can be useful in case when the instance doesn't have a public IP address and SSH access is provided through a 
-tunnel to a local port.
+- __`ports`__ _(optional)_ - list of ports to open on the instance. For example:
+    ```yaml
+    ports: [6006, 8888]
+    ```
+    It will open ports 6006 for TensorBoard and 8888 for Jupyter Notebook. 
+
+- __`localSshPort`__ _(optional)_ - if this parameter is set, all the Spotty commands will create SSH connections 
+with the instance using the IP address __127.0.0.1__ and the specified port. This can be useful in case when an 
+instance doesn't have a public IP address and a jump-server is used for tunneling.
 
 - __`managedPolicyArns`__ _(optional)_ - a list of Amazon Resource Names (ARNs) of the IAM managed policies that 
 you want to attach to the instance role. Read more about Managed Policies 
-[here](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html){:target="_blank"}.
+[here](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html).
 
-- __`commands`__ _(optional)_ - commands that should be run on the host OS before a container is started. 
+- __`instanceProfileArn`__ _(optional)_ - an Amazon Resource Name (ARN) of the IAM Instance Profile that you'd like
+to attach to the instance. Read more about Instance Profiles
+[here](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html).
+
+- __`commands`__ _(optional)_ - commands that should be run on the host OS before the container is started. 
 For example, you could login to Amazon ECR to pull a Docker image from there 
-([Deep Learning Containers Images](https://docs.aws.amazon.com/dlami/latest/devguide/deep-learning-containers-images.html){:target="_blank"}):
+([Deep Learning Containers Images](https://docs.aws.amazon.com/dlami/latest/devguide/deep-learning-containers-images.html)):
     ```yaml
     commands: |
       $(aws ecr get-login --no-include-email --region us-east-2 --registry-ids 763104351884)
     ```
+
+
+[configuration file]: </docs/user-guide/configuration-file>
+[Caching Docker Image on an EBS Volume]: </docs/providers/aws/caching-docker-image-on-an-ebs-volume>
+[EBS Volumes and Deletion Policies]: </docs/providers/aws/ebs-volumes-and-deletion-policies>
diff --git a/docs/source/docs/providers/aws/overview.rst b/docs/source/docs/providers/aws/overview.rst
new file mode 100644
index 0000000..4df09e4
--- /dev/null
+++ b/docs/source/docs/providers/aws/overview.rst
@@ -0,0 +1,9 @@
+AWS Provider Overview
+=====================
+
+.. toctree::
+
+    instance-parameters
+    ebs-volumes-and-deletion-policies
+    caching-docker-image-on-an-ebs-volume
+    faq
diff --git a/docs/docs/gcp/account_preparation.md b/docs/source/docs/providers/gcp/account-preparation.md
similarity index 75%
rename from docs/docs/gcp/account_preparation.md
rename to docs/source/docs/providers/gcp/account-preparation.md
index f97eccf..14120b8 100644
--- a/docs/docs/gcp/account_preparation.md
+++ b/docs/source/docs/providers/gcp/account-preparation.md
@@ -1,21 +1,13 @@
----
-layout: default
-title: Account Preparation
-parent: GCP Provider (beta)
-nav_order: 1
-permalink: /docs/gcp-provider/account-preparation/
----
+# GCP Account Preparation
 
-# Account Preparation
-
-1. [Create a project](https://console.cloud.google.com/projectcreate){:target="_blank"} 
+1. [Create a project](https://console.cloud.google.com/projectcreate) 
 if you don't have one already.
-2. Enable the [Deployment Manager API](https://console.cloud.google.com/apis/library/deploymentmanager.googleapis.com){:target="_blank"} 
+2. Enable the [Deployment Manager API](https://console.cloud.google.com/apis/library/deploymentmanager.googleapis.com) 
 for the created project.
-3. Enable the [Runtime Configuration API](https://console.developers.google.com/apis/library/runtimeconfig.googleapis.com){:target="_blank"} 
+3. Enable the [Runtime Configuration API](https://console.developers.google.com/apis/library/runtimeconfig.googleapis.com) 
 for the created project.
-4. [Create a service account](https://console.cloud.google.com/iam-admin/serviceaccounts/create){:target="_blank"}.
-5. Go to the [IAM page](https://console.cloud.google.com/iam-admin/iam){:target="_blank"} and add the following 
+4. [Create a service account](https://console.cloud.google.com/iam-admin/serviceaccounts/create).
+5. Go to the [IAM page](https://console.cloud.google.com/iam-admin/iam) and add the following 
 roles to the created service account:
     1. _Compute Admin_
     2. _Storage Admin_
@@ -23,11 +15,11 @@ roles to the created service account:
     4. _Cloud RuntimeConfig Admin_
 6. Make sure you have a quota to run GPU instances:
     1. Go to the quotas page in "IAM & admin" and filter the list of services by setting the Metric 
-    field to __"GPUs (all regions)"__: [https://console.cloud.google.com/iam-admin/quotas?metric=GPUs%20(all%20regions)](https://console.cloud.google.com/iam-admin/quotas?metric=GPUs%20(all%20regions)){:target="_blank"}.
+    field to __"GPUs (all regions)"__: [https://console.cloud.google.com/iam-admin/quotas?metric=GPUs%20(all%20regions)](https://console.cloud.google.com/iam-admin/quotas?metric=GPUs%20(all%20regions)).
     2. Check the limit for the __"Compute Engine API"__ service. If it's a zero, select the service and 
     click the __"[+] EDIT QUOTAS"__ button at the top of the page.
     3. Set a new quota limit to 1 or more and submit the request.
-7. [Install Google Cloud SDK](https://cloud.google.com/sdk/install){:target="_blank"}.
+7. [Install Google Cloud SDK](https://cloud.google.com/sdk/install).
 8. Before using Spotty commands like `spotty start`, `spotty run` and others, make sure that the 
 `GOOGLE_APPLICATION_CREDENTIALS` environmental variable is set up and contains the path to your service 
 account key file:
diff --git a/docs/source/docs/providers/gcp/caching-docker-image-on-a-disk.md b/docs/source/docs/providers/gcp/caching-docker-image-on-a-disk.md
new file mode 100644
index 0000000..d59b244
--- /dev/null
+++ b/docs/source/docs/providers/gcp/caching-docker-image-on-a-disk.md
@@ -0,0 +1,27 @@
+# Caching Docker Image on a Disk
+
+You can cache images that you've built or downloaded from the internet on a disk that you attach to the instance.
+
+A configuration file has the "__dockerDataRoot__" parameter. It's a directory on the host OS where the Docker 
+daemon will save all the images.
+
+Specify the `moundDir` directory for one of the instance volumes and set the `dockerDataRoot` parameter
+to the same value (or to a subdirectory of the `moundDir` directory).
+
+Example:
+```yaml
+# ...
+
+instances:
+  - name: gcp-1
+    provider: gcp
+    parameters:
+      # ...
+      dockerDataRoot: /docker
+      volumes:
+        # ...
+        - name: docker
+          parameters:
+            size: 10
+            mountDir: /docker
+```
diff --git a/docs/source/docs/providers/gcp/disks-and-deletion-policies.md b/docs/source/docs/providers/gcp/disks-and-deletion-policies.md
new file mode 100644
index 0000000..b00fbae
--- /dev/null
+++ b/docs/source/docs/providers/gcp/disks-and-deletion-policies.md
@@ -0,0 +1,14 @@
+# Disks and Deletion Policies
+
+By default, disks have names in the following format: `<PROJECT_NAME>-<INSTANCE_NAME>-<VOLUME_NAME>`.
+But you can specify a custom name using the `diskName` parameter. 
+
+When you're starting an instance:
+1. Spotty is looking for existing disks using their names. If a disk exists, it will be attached to the 
+instance.
+2. If not - Spotty will be looking for a snapshot with the same name. If the snapshot exists, the disk will be 
+restored from that snapshot.
+3. If neither snapshot nor disk with this name exists, a new disk will be created. 
+
+__Note:__ Deletion Policies for the GCP provider are not implemented yet, so, regardless of the `deletionPolicy` 
+parameter value, created disks will retain when the instance is terminated.
diff --git a/docs/docs/gcp/instance_parameters.md b/docs/source/docs/providers/gcp/instance-parameters.md
similarity index 64%
rename from docs/docs/gcp/instance_parameters.md
rename to docs/source/docs/providers/gcp/instance-parameters.md
index d89b07e..230d3ac 100644
--- a/docs/docs/gcp/instance_parameters.md
+++ b/docs/source/docs/providers/gcp/instance-parameters.md
@@ -1,34 +1,28 @@
----
-layout: default
-title: Instance Parameters
-parent: GCP Provider (beta)
-nav_order: 2
-permalink: /docs/gcp-provider/instance-parameters/
----
+# Instance Parameters
 
-# GCP Instance Parameters
+Instance parameters are part of the [configuration file], but for each provider they are different. 
+Here you can find parameters for a GCP instance:
 
-Instance parameters are part of the [configuration file]({{ site.baseurl }}/docs/configuration-file/), but 
-for each provider they are different. Here you can find parameters for a GCP instance:
+- __`containerName`__ _(optional)_ - a name of the container from the `containers` section.
+Default value: `default`.
 
 - __`zone`__ - GCP zone where to run an instance.
 
 - __`machineType`__ - a type of the instance to run. You can find a list of predefined machine types
-here: [Machine Types](https://cloud.google.com/compute/docs/machine-types){:target="_blank"}. If you in doubt what to use,
+here: [Machine Types](https://cloud.google.com/compute/docs/machine-types). If you in doubt what to use,
 just go for `n1-standard-1`. To attach GPUs to the selected machine type, use the `gpu` parameter (see 
 the details below).
 
 - __`gpu`__ _(optional)_ - _a dictionary with keys `type` and `count`_:
     - __`type`__ - a type of GPU to attach to the instance. Read more about GPUs and their availabily
-    in different zones here: [GPUs on Compute Engine](https://cloud.google.com/compute/docs/gpus/){:target="_blank"}.
+    in different zones here: [GPUs on Compute Engine](https://cloud.google.com/compute/docs/gpus/).
     - __`count`__ _(optional)_ - a number of GPUs that should be attached to the instance. The default
     value is 1. See here a number of GPUs that you can attach to different machine types: 
-    [Valid numbers of GPUs for each machine type](https://cloud.google.com/ml-engine/docs/tensorflow/using-gpus#gpu-compatibility-table){:target="_blank"}.
+    [Valid numbers of GPUs for each machine type](https://cloud.google.com/ml-engine/docs/tensorflow/using-gpus#gpu-compatibility-table).
 
-- __`onDemandInstance`__ _(optional)_ - run an on-demand instance instead of a preemptible one (available values are 
-"true" or "false"). By default, this value is "false", so Spotty will be running a preemptible instance. __Note:__ be 
-aware that GCP terminates preemptible instances in 24 hours. Read more about Preemptible VMs 
-[here](https://cloud.google.com/compute/docs/instances/preemptible){:target="_blank"}.
+- __`preemptibleInstance`__ _(optional)_ - if set to `true`, runs a preemptible instance instead of an on-demand 
+instance. __Note:__ be aware that GCP terminates preemptible instances in 24 hours. Read more about Preemptible VMs 
+[here](https://cloud.google.com/compute/docs/instances/preemptible).
 
 - __`imageName`__ _(optional)_ - a name of the image with NVIDIA Docker in the current GCP project. You can use 
 the `spotty gcp create-image` command to create it. By default, the command will create an image with the name 
@@ -45,7 +39,7 @@ the instance is terminated. Use attached volumes to store the data that you need
 parameter below).
 
 - __`dockerDataRoot`__ _(optional)_ - directory where Docker will store all downloaded and built images. 
-Read more: [How to cache a Docker image]({{ site.baseurl }}/docs/faq/#how-to-cache-a-docker-image).
+Read more: [Caching Docker Image on a Disk].
 
 - __`volumes`__ _(optional)_ - the list of volumes to attach to the instance:
     - __`name`__ - a name of the volume. This name should match one of the container's `volumeMounts` to have this 
@@ -56,11 +50,10 @@ Read more: [How to cache a Docker image]({{ site.baseurl }}/docs/faq/#how-to-cac
         the existing snapshot but can be increased.
 
         - __`deletionPolicy`__ _(optional)_ - what to do with the disk once the instance is terminated using the 
-        `spotty stop` command. Possible values include: "__create_snapshot__" _(value by default)_, "__update_snapshot__", 
-        "__retain__" and  "__delete__". Read more: 
-        [Disks and Deletion Policies]({{ site.baseurl }}/docs/gcp-provider/disks-and-deletion-policies/).
+        `spotty stop` command. Possible values include: "__Retain__" _(value by default)_, "__CreateSnapshot__", 
+        "__UpdateSnapshot__" and  "__Delete__". Read more: [Disks and Deletion Policies].
         
-            __(!) Note:__ Deletion Policies are not implemented yet, so your disks will always retain.
+            __(!) Note:__ Deletion Policies are not implemented yet, so created disks will always retain.
 
         - __`diskName`__ _(optional)_ - name of the disk. The default name is 
         "{project_name}-{instance_name}-{volume_name}".
@@ -68,9 +61,19 @@ Read more: [How to cache a Docker image]({{ site.baseurl }}/docs/faq/#how-to-cac
         - __`mountDir`__ _(optional)_ - directory where the disk will be mounted on the instance. The default 
         directory is "/mnt/{disk_name}".
 
-- __`localSshPort`__ _(optional)_ - if the local SSH port is specified, the commands `spotty ssh`, `spotty run` 
-and `spotty sync` will do SSH connections to the instance using the IP address __127.0.0.1__ and the specified port. 
-It can be useful in case when the instance doesn't have a public IP address and SSH access is provided through a 
-tunnel to a local port.
+- __`ports`__ _(optional)_ - list of ports to open on the instance. For example:
+    ```yaml
+    ports: [6006, 8888]
+    ```
+    It will open ports 6006 for TensorBoard and 8888 for Jupyter Notebook. 
 
-- __`commands`__ _(optional)_ - commands that should be run on the host OS before a container is started.
+- __`localSshPort`__ _(optional)_ - if this parameter is set, all the Spotty commands will create SSH connections 
+with the instance using the IP address __127.0.0.1__ and the specified port. This can be useful in case when an 
+instance doesn't have a public IP address and a jump-server is used for tunneling.
+
+- __`commands`__ _(optional)_ - commands that should be run on the host OS before the container is started.
+
+
+[configuration file]: </docs/user-guide/configuration-file>
+[Caching Docker Image on a Disk]: </docs/providers/gcp/caching-docker-image-on-a-disk>
+[Disks and Deletion Policies]: </docs/providers/gcp/disks-and-deletion-policies>
diff --git a/docs/source/docs/providers/gcp/overview.rst b/docs/source/docs/providers/gcp/overview.rst
new file mode 100644
index 0000000..76b0e3b
--- /dev/null
+++ b/docs/source/docs/providers/gcp/overview.rst
@@ -0,0 +1,9 @@
+GCP Provider Overview
+=====================
+
+.. toctree::
+
+    account-preparation
+    instance-parameters
+    disks-and-deletion-policies
+    caching-docker-image-on-a-disk
diff --git a/docs/source/docs/providers/local/instance-parameters.md b/docs/source/docs/providers/local/instance-parameters.md
new file mode 100644
index 0000000..604c5aa
--- /dev/null
+++ b/docs/source/docs/providers/local/instance-parameters.md
@@ -0,0 +1,16 @@
+# Instance Parameters
+
+Instance parameters are part of the [configuration file], but for each provider they are different. 
+Here you can find parameters for a local instance:
+
+- __`containerName`__ _(optional)_ - a name of the container from the `containers` section.
+Default value: `default`.
+
+- __`volumes`__ _(optional)_ - the list of volumes to attach to the instance:
+    - __`name`__ - a name of the volume. This name should match one of the container's `volumeMounts` to have this 
+    volume attached to the container's filesystem.
+
+    - __`parameters`__ _(optional)_ - parameters of the volume:
+        - __`path`__ _(optional)_ - a path on a local instance that should be mounted to the container.
+
+[configuration file]: </docs/user-guide/configuration-file>
diff --git a/docs/source/docs/providers/local/overview.rst b/docs/source/docs/providers/local/overview.rst
new file mode 100644
index 0000000..94ec430
--- /dev/null
+++ b/docs/source/docs/providers/local/overview.rst
@@ -0,0 +1,6 @@
+Local Provider Overview
+=======================
+
+.. toctree::
+
+    instance-parameters
diff --git a/docs/source/docs/providers/remote/instance-parameters.md b/docs/source/docs/providers/remote/instance-parameters.md
new file mode 100644
index 0000000..f758755
--- /dev/null
+++ b/docs/source/docs/providers/remote/instance-parameters.md
@@ -0,0 +1,16 @@
+# Instance Parameters
+
+Instance parameters are part of the [configuration file], but for each provider they are different. 
+Here you can find parameters for a remote instance:
+
+- __`containerName`__ _(optional)_ - a name of the container from the `containers` section.
+Default value: `default`.
+
+- __`volumes`__ _(optional)_ - the list of volumes to attach to the instance:
+    - __`name`__ - a name of the volume. This name should match one of the container's `volumeMounts` to have this 
+    volume attached to the container's filesystem.
+
+    - __`parameters`__ _(optional)_ - parameters of the volume:
+        - __`path`__ _(optional)_ - a path on a remote instance that should be mounted to the container.
+
+[configuration file]: </docs/user-guide/configuration-file>
diff --git a/docs/source/docs/providers/remote/overview.rst b/docs/source/docs/providers/remote/overview.rst
new file mode 100644
index 0000000..901a6cf
--- /dev/null
+++ b/docs/source/docs/providers/remote/overview.rst
@@ -0,0 +1,6 @@
+Remote Provider Overview
+========================
+
+.. toctree::
+
+    instance-parameters
diff --git a/docs/source/docs/user-guide/configuration-file.md b/docs/source/docs/user-guide/configuration-file.md
new file mode 100644
index 0000000..ad7b285
--- /dev/null
+++ b/docs/source/docs/user-guide/configuration-file.md
@@ -0,0 +1,227 @@
+# Spotty Configuration File
+
+By default, Spotty is looking for a `spotty.yaml` file in the root directory of the project. This file describes 
+parameters of a remote instance and an environment for the project. Here is a basic example of such file for AWS:
+
+```yaml
+project:
+  name: my-project-name
+  syncFilters:
+    - exclude:
+      - .git/*
+      - .idea/*
+      - '*/__pycache__/*'
+
+containers:
+  - projectDir: /workspace/project
+    image: tensorflow/tensorflow:latest-gpu-py3-jupyter
+    env:
+      PYTHONPATH: /workspace/project
+    ports:
+      # TensorBoard
+      - containerPort: 6006
+        hostPort: 6006
+      # Jupyter
+      - containerPort: 8888
+        hostPort: 8888
+    volumeMounts:
+      - name: workspace
+        mountPath: /workspace
+
+instances:
+  - name: aws-1
+    provider: aws
+    parameters:
+      region: eu-west-1
+      instanceType: p2.xlarge
+      ports: [6006, 8888]
+      volumes:
+        - name: workspace
+          parameters:
+            size: 50
+
+scripts:
+  tensorboard: |
+    tensorboard --bind_all --port 6006 --logdir /workspace/project/training
+  jupyter: |
+    jupyter notebook --allow-root --ip 0.0.0.0 --notebook-dir=/workspace/project
+```
+
+Instance parameters are different for each provider:
+
+- [Local Provider Instance Parameters]
+- [Remote Provider Instance Parameters]
+- [AWS Provider Instance Parameters]
+- [GCP Provider Instance Parameters]
+
+
+## Available Parameters
+
+Configuration file consists of 4 sections: `project`, `containers`, `instances` and `scripts`.
+
+### __`project`__ section
+
+The `project` section contains the following parameters:
+
+- __`name`__ - the name of your project. It will be used to create an S3 bucket and a CloudFormation stack to run 
+an instance.
+
+- __`syncFilters`__ _(optional)_ - filters to skip some directories or files during synchronization. By default, all 
+project files will be synced with the instance. Example:
+    ```yaml
+    syncFilters:
+      - exclude:
+          - .idea/*
+          - .git/*
+          - data/*
+      - include:
+          - data/test/*
+      - exclude:
+          - data/test/dump.json
+    ```
+    
+    It will skip ".idea/", ".git/" and "data/" directories except the "data/test/" directory. All files from 
+    the "data/test/" directory will be synced with the instance except the "data/test/dump.json" file.
+    
+    You can read more about filters 
+    here: [Use of Exclude and Include Filter](https://docs.aws.amazon.com/cli/latest/reference/s3/index.html#use-of-exclude-and-include-filters). 
+
+### __`containers`__ section
+
+The `containers` section contains a list of containers where each container is described 
+with the following parameters:
+
+- __`name`__ - a name of the container. You can associate containers with the instances using the `containerName`
+parameter in the instance configuration. Default value: `default`.
+
+- __`projectDir`__ - a directory inside the container where the local project will be copied. If
+it's a subdirectory of a container volume, the project will be located on that volume, 
+otherwise, the data will be lost once the instance is terminated.
+
+- __`image`__ _(optional)_ - the name of the Docker image that contains the environment for your project. For example, 
+you could use [TensorFlow image for GPU](https://hub.docker.com/r/tensorflow/tensorflow/) 
+(`tensorflow/tensorflow:latest-gpu-py3-jupyter`). It already contains NumPy, SciPy, scikit-learn, pandas, Jupyter Notebook and 
+TensorFlow itself. If you need to use your own image, you can specify the path to your Dockerfile in the 
+__`file`__ parameter (see below), or push your image to the [Docker Hub](https://hub.docker.com/).
+
+- __`file`__ _(optional)_ - relative path to your custom Dockerfile.
+    
+    __Note:__ Spotty uses the directory with the Dockerfile as its build context, so make sure it doesn't contain 
+    gigabytes of irrelevant data (keep the Dockerfile in a separate directory or use the `.dockerignore` file). 
+    Otherwise, you may get an out-of-space error because Docker copies the entire build context to the Docker daemon 
+    during the build. Read more here: ["docker build" command](https://docs.docker.com/engine/reference/commandline/build/).
+
+    __Example:__ if you use TensorFlow and need to download your dataset from S3, you could install 
+    [AWS CLI](https://github.com/aws/aws-cli) on top of the original TensorFlow image. Just create a 
+    `Dockerfile` in the `docker/` directory of your project:
+    ```dockerfile
+    FROM tensorflow/tensorflow:latest-gpu-py3-jupyter
+
+    RUN pip install awscli
+    ```
+
+    Then set the `file` parameter to `docker/Dockerfile`.
+
+- __`runAsHostUser`__ _(optional)_ - if set to `true`, the container will be run with the host user ID and group ID,
+
+- __`volumeMounts`__ _(optional)_ - where to mount instance volumes into the container's filesystem. Each element 
+of a list has the following parameters:
+    - __`name`__ - this must match the name of an instance volume.
+    - __`mountPath`__ - a path within the container at which the volume should be mounted.
+
+- __`workingDir`__ _(optional)_ - working directory for your custom scripts (see "scripts" section below),
+
+- __`env`__ _(optional)_ - a dictionary with environmental variables that will be available in the container,
+
+- __`hostNetwork`__ _(optional)_ - if set to `true`, the Docker container will be run with the host network,
+
+- __`ports`__ _(optional)_ - container ports that should be published to the host. Each element of a list 
+contains the following parameters:
+    - __`containerPort`__ - a container port,
+    - __`hostPort`__ _(optional)_ - a host port. By default, the container port will be published on a random 
+    host port.
+
+- __`commands`__ _(optional)_ - commands which should be performed once your container is started. For example, you 
+could download your datasets from an S3 bucket to the project directory (see "project" section):
+    ```yaml
+    commands: |
+      aws s3 sync s3://my-bucket/datasets/my-dataset /workspace/project/data
+    ```
+
+- __`runtimeParameters`__ _(optional)_ - a list of additional parameters for the container runtime. For example:
+    ```yaml
+    runtimeParameters: ['--privileged', '--shm-size', '2G']
+    ```
+
+### __`instances`__ section
+
+The `instances` section contains a list of instances where each instance is described 
+with the following parameters:
+
+- __`name`__ - a name of the instance. Use this name to manage the instance with the commands like 
+"spotty start" or "spotty stop". Also Spotty uses this name in the names of AWS and GCP resources.
+
+- __`provider`__ - a provider for the instance. At the moment Spotty supports 4 providers:
+    - "__local__" - runs containers using the Docker installed on the local machine,
+    - "__remote__" - runs containers on a remote machine through SSH,
+    - "__aws__" - Amazon Web Services EC2 instances,
+    - "__gcp__" - Google Cloud Platform VMs.
+
+- __`parameters`__ - parameters of the instance. These parameters are different for each provider:
+    - [Local Provider Instance Parameters]
+    - [Remote Provider Instance Parameters]
+    - [AWS Provider Instance Parameters]
+    - [GCP Provider Instance Parameters]
+
+### __`scripts`__ section
+
+The `scripts` section contains custom scripts which can be run with the `spotty run <SCRIPT_NAME>` 
+command. The following example defines 2 scripts: `jupyter` - to run Jupyter server and `train` - 
+to start training a model:
+
+```yaml
+scripts:
+  jupyter: |
+    jupyter notebook --allow-root --ip 0.0.0.0 --notebook-dir=/workspace/project
+
+  train: |
+    if [ -n "{{MODEL}}" ]; then
+      python /workspace/project/model/train.py --model-name {{MODEL}}
+    else
+      echo "The MODEL parameter is required."
+    fi
+```
+
+To start Jupyter simply run:
+```bash
+spotty run jupyter
+```
+
+It will start Jupyter server on the remote instance inside a tmux session. Jupyter will be available on the port
+specified in the container configuration (see the example on top of the page).
+
+Copy an authentication token from the command output and use the __`Ctrl + b`__, then __`d`__ combination of keys 
+to detach the tmux session - Jupyter will keep running.
+
+You also can write parametrized scripts. For example, the `train` script contains the `MODEL` parameter. So you
+could run your training script with different model names:
+
+```bash
+spotty run train -p MODEL=my-model
+```
+
+Use the __`Ctrl + b`__, then __`d`__ combination of keys to detached tmux session - the script will keep running. 
+
+You can come back to the running script the following ways:
+- either use the same command again - you will be reattached to the existing tmux session,
+- or connect to the instance using the `spotty sh` command and then use the __`Ctrl + b`__, 
+then __`s`__ combination of keys to switch into the right tmux session.
+
+__Note:__ don't forget to use the "|" character for multi-line scripts, otherwise the YAML parser
+will merge multiple lines together.
+
+
+[Local Provider Instance Parameters]: </docs/providers/local/instance-parameters>
+[Remote Provider Instance Parameters]: </docs/providers/remote/instance-parameters>
+[AWS Provider Instance Parameters]: </docs/providers/aws/instance-parameters>
+[GCP Provider Instance Parameters]: </docs/providers/gcp/instance-parameters>
diff --git a/docs/source/docs/user-guide/getting-started.md b/docs/source/docs/user-guide/getting-started.md
new file mode 100644
index 0000000..2b492fa
--- /dev/null
+++ b/docs/source/docs/user-guide/getting-started.md
@@ -0,0 +1,56 @@
+# Getting Started
+
+## Installation
+
+Use [pip](http://www.pip-installer.org/en/latest/) to install or upgrade Spotty:
+
+```bash
+pip install -U spotty
+```
+
+Python >=3.6 is required.
+
+Also, depending on the use case, some additional software is needed:
+
+* __Docker__ if you want to run containers locally: [Get Docker](https://docs.docker.com/get-docker/)
+* __AWS CLI__ if you're going to use AWS: [Installing the AWS Command Line Interface](http://docs.aws.amazon.com/cli/latest/userguide/installing.html)
+* __Google Cloud SDK__ if you're going to use GCP: [Installing Google Cloud SDK](https://cloud.google.com/sdk/install)
+
+
+## Prepare a configuration file
+
+Prepare a `spotty.yaml` file and put it to the root directory of your project:
+
+   - See the file specification and an example here: [Spotty Configuration File].
+   - Read [this](https://medium.com/@apls/how-to-train-deep-learning-models-on-aws-spot-instances-using-spotty-8d9e0543d365) 
+   article for a real-world example.
+
+## Start an instance
+
+Use the following command to launch an instance with the Docker container:
+    
+```bash
+spotty start
+```
+
+If you're using AWS, it will create EBS volumes if needed, start an instance, upload project files and start 
+the Docker container with the environment for your project.
+
+## Train your models or run notebooks
+
+To connect to the running container via SSH, use the following command:
+
+```bash
+spotty sh
+```
+
+It runs a [tmux](https://github.com/tmux/tmux/wiki) session, so you can always detach this session using
+__`Ctrl + b`__, then __`d`__ combination of keys. To be attached to that session later, just use the
+`spotty sh` command again.
+
+Also, you can run custom scripts inside the Docker container using the `spotty run <SCRIPT_NAME>` command. Read more
+about custom scripts in the documentation: [Configuration File: "scripts" section].
+
+
+[Spotty Configuration File]: </docs/user-guide/configuration-file>
+[Configuration File: "scripts" section]: <docs/user-guide/configuration-file:scripts section>
diff --git a/docs/source/docs/user-guide/installation.md b/docs/source/docs/user-guide/installation.md
new file mode 100644
index 0000000..d5cf6f8
--- /dev/null
+++ b/docs/source/docs/user-guide/installation.md
@@ -0,0 +1,15 @@
+# Installation
+
+Use [pip](http://www.pip-installer.org/en/latest/) to install or upgrade Spotty:
+
+```bash
+pip install -U spotty
+```
+
+Python >=3.6 is required.
+
+Also, depending on the use case, some additional software is needed:
+
+* __Docker__ if you want to run containers locally: [Get Docker](https://docs.docker.com/get-docker/)
+* __AWS CLI__ if you're going to use AWS: [Installing the AWS Command Line Interface](http://docs.aws.amazon.com/cli/latest/userguide/installing.html)
+* __Google Cloud SDK__ if you're going to use GCP: [Installing Google Cloud SDK](https://cloud.google.com/sdk/install)
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 0000000..bb03c93
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,25 @@
+.. raw:: html
+   :file: main.html
+
+Welcome to Spotty Documentation
+===============================
+
+.. toctree::
+   :hidden:
+   :maxdepth: 2
+   :caption: User Guide
+
+   docs/user-guide/getting-started
+   docs/user-guide/installation
+   docs/user-guide/configuration-file
+   docs/cli/spotty
+
+.. toctree::
+   :hidden:
+   :maxdepth: 2
+   :caption: Providers
+
+   Local Provider <docs/providers/local/overview>
+   Remote Provider <docs/providers/remote/overview>
+   AWS Provider <docs/providers/aws/overview>
+   GCP Provider <docs/providers/gcp/overview>
diff --git a/docs/source/main.html b/docs/source/main.html
new file mode 100644
index 0000000..be8e0a1
--- /dev/null
+++ b/docs/source/main.html
@@ -0,0 +1,123 @@
+<style>
+    #main-page-title {
+        text-align: center;
+        font-family: Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;
+        font-weight: normal;
+        margin-bottom: 30px;
+    }
+
+    #main-page-buttons {
+        text-align: center;
+        margin-bottom: 70px;
+    }
+
+    #main-page-buttons .btn.btn-get-started {
+        background-color: #e64759 !important;
+    }
+
+    #main-page-buttons .btn.btn-get-started:hover {
+        background-color: #c64b60 !important;
+    }
+
+    #main-page-features td.block {
+        width: 50%;
+        padding-bottom: 50px;
+    }
+
+    #main-page-features td.block .icon {
+        width: 100px;
+        text-align: center;
+        height: 100%;
+    }
+
+    #main-page-features td.block .title {
+        font-weight: bold;
+        margin-bottom: 10px;
+        font-size: 18px;
+    }
+
+    #main-page-features td.block .description {
+        color: #888;
+    }
+</style>
+
+<div class="page-content">
+    <div style="text-align: center; margin-bottom: 20px;">
+        <img src="_static/images/logo_740x240.png" style="height: 120px;"/>
+    </div>
+
+    <h1 id="main-page-title">
+        An Open-source Tool for Training<br />Deep Learning Models in the Cloud
+    </h1>
+
+    <div id="main-page-buttons">
+        <a href="docs/user-guide/getting-started.html" class="btn btn-get-started">
+            <i class="fa fa-terminal"></i> Get started now
+        </a>
+        <a href="https://github.com/apls777/spotty" target="_blank" class="btn btn-neutral">
+            <i class="fa fa-github"></i> View it on GitHub
+        </a>
+    </div>
+
+    <div id="main-page-features">
+        <table>
+            <tr>
+                <td class="block">
+                    <table>
+                        <tr>
+                            <td class="icon">
+                                <i class="fa fa-cloud-upload fa-4x" style="color: #a35063;"></i>
+                            </td>
+                            <td class="text">
+                                <div class="title">Run Training on AWS and GCP Instances</div>
+                                <div class="description">Spotty makes training of deep learning models on AWS and GCP instances as simple as training on your local machine.</div>
+                            </td>
+                        </tr>
+                    </table>
+                </td>
+                <td class="block">
+                    <table>
+                        <tr>
+                            <td class="icon">
+                                <i class="fa fa-dollar fa-4x" style="color: #2d9681;"></i>
+                            </td>
+                            <td class="text">
+                                <div class="title">Reduce Training Costs</div>
+                                <div class="description">Spotty can save you up to 70% of the costs by using <a href="https://aws.amazon.com/ec2/spot/">AWS Spot Instances</a>
+                                    or <a href="https://cloud.google.com/preemptible-vms/">GCP Preemtible VMs</a>.</div>
+                            </td>
+                        </tr>
+                    </table>
+                </td>
+            </tr>
+            <tr>
+                <td class="block">
+                    <table>
+                        <tr>
+                            <td class="icon">
+                                <i class="fa fa-github fa-4x"></i>
+                            </td>
+                            <td class="text">
+                                <div class="title">Share Your Model with Everyone</div>
+                                <div class="description">Spotty makes your model trainable locally or in the cloud by everyone with a couple of commands.</div>
+                            </td>
+                        </tr>
+                    </table>
+                </td>
+                <td class="block">
+                    <table>
+                        <tr>
+                            <td class="icon">
+                                <i class="fa fa-cube fa-4x" style="color: #1f89c4;"></i>
+                            </td>
+                            <td class="text">
+                                <div class="title">Develop with Docker</div>
+                                <div class="description">Spotty helps you to develop your model locally using a Docker container, so the environment can be set up by anyone and anywhere with a single command.</div>
+                            </td>
+                        </tr>
+                    </table>
+                </td>
+            </tr>
+        </table>
+    </div>
+</div>
diff --git a/setup.py b/setup.py
index 35f47fd..3743aee 100644
--- a/setup.py
+++ b/setup.py
@@ -36,7 +36,10 @@ def get_description():
       long_description_content_type='text/markdown',
       packages=find_packages(exclude=['tests*']),
       package_data={
-          'spotty.providers.aws.deployment.cf_templates': ['data/*.yaml'],
+          'spotty.providers.aws.deployment.cf_templates': [
+              'data/*.yaml',
+              'data/instance/**/*',
+          ],
           'spotty.providers.gcp.deployment.dm_templates': ['image/*.yaml', 'instance/*.yaml'],
       },
       scripts=['bin/spotty'],
diff --git a/spotty/__init__.py b/spotty/__init__.py
index daab838..19b4f1d 100644
--- a/spotty/__init__.py
+++ b/spotty/__init__.py
@@ -1,1 +1,1 @@
-__version__ = '1.2.4'
+__version__ = '1.3.0'
diff --git a/spotty/cli.py b/spotty/cli.py
new file mode 100644
index 0000000..4b2d093
--- /dev/null
+++ b/spotty/cli.py
@@ -0,0 +1,50 @@
+import argparse
+from typing import List, Type
+import pkg_resources
+from spotty.commands.abstract_command import AbstractCommand
+from spotty.commands.aws import AwsCommand
+from spotty.commands.download import DownloadCommand
+from spotty.commands.exec import ExecCommand
+from spotty.commands.run import RunCommand
+from spotty.commands.sh import ShCommand
+from spotty.commands.start import StartCommand
+from spotty.commands.status import StatusCommand
+from spotty.commands.stop import StopCommand
+from spotty.commands.sync import SyncCommand
+
+
+def get_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser()
+    parser.add_argument('-V', '--version', action='store_true', help='Display the version of the Spotty')
+
+    command_classes = [
+       StartCommand,
+       StopCommand,
+       StatusCommand,
+       ShCommand,
+       RunCommand,
+       ExecCommand,
+       SyncCommand,
+       DownloadCommand,
+       AwsCommand,
+    ] + _get_custom_commands()
+
+    # add commands to the parser
+    add_subparsers(parser, command_classes)
+
+    return parser
+
+
+def add_subparsers(parser: argparse.ArgumentParser, command_classes: List[Type[AbstractCommand]]):
+    """Adds commands to the parser."""
+    subparsers = parser.add_subparsers()
+    for command_class in command_classes:
+        command = command_class()
+        subparser = subparsers.add_parser(command.name, help=command.description, description=command.description)
+        subparser.set_defaults(command=command, parser=subparser)
+        command.configure(subparser)
+
+
+def _get_custom_commands() -> List[Type[AbstractCommand]]:
+    """Returns custom commands that integrated through entry points."""
+    return [entry_point.load() for entry_point in pkg_resources.iter_entry_points('spotty.commands')]
diff --git a/spotty/commands/abstract_config_command.py b/spotty/commands/abstract_config_command.py
index 4eceb2e..6c23fca 100644
--- a/spotty/commands/abstract_config_command.py
+++ b/spotty/commands/abstract_config_command.py
@@ -1,12 +1,9 @@
 from abc import abstractmethod
-import yaml
-import os
+from typing import List
 from argparse import Namespace, ArgumentParser
-from spotty.config.project_config import ProjectConfig
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
+from spotty.config.config_utils import load_config
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
 from spotty.providers.instance_manager_factory import InstanceManagerFactory
-from spotty.utils import filter_list
-from yaml.scanner import ScannerError
 from spotty.commands.abstract_command import AbstractCommand
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
 
@@ -24,25 +21,12 @@ def configure(self, parser: ArgumentParser):
         parser.add_argument('instance_name', metavar='INSTANCE_NAME', nargs='?', type=str, help='Instance name')
 
     def run(self, args: Namespace, output: AbstractOutputWriter):
-        # get project directory
-        config_path = args.config
-        if not config_path:
-            config_path = 'spotty.yaml'
-
-        if os.path.isabs(config_path):
-            config_abs_path = config_path
-        else:
-            config_abs_path = os.path.abspath(os.path.join(os.getcwd(), config_path))
-
-        if not os.path.exists(config_abs_path):
-            raise ValueError('Configuration file "%s" not found.' % config_path)
-
         # get project configuration
-        project_dir = os.path.dirname(config_abs_path)
-        project_config = ProjectConfig(self._load_config(config_abs_path), project_dir)
+        project_config = load_config(args.config)
 
         # get instance configuration
-        instance_config = self._get_instance_config(project_config, args.instance_name, output)
+        instance_id = self._get_instance_id(project_config.instances, args.instance_name, output)
+        instance_config = project_config.instances[instance_id]
 
         # create an instance manger
         instance_manager = InstanceManagerFactory.get_instance(project_config, instance_config)
@@ -51,26 +35,13 @@ def run(self, args: Namespace, output: AbstractOutputWriter):
         self._run(instance_manager, args, output)
 
     @staticmethod
-    def _load_config(config_path: str):
-        """Returns project configuration."""
-        config = None
-        if os.path.exists(config_path):
-            with open(config_path, 'r') as f:
-                try:
-                    config = yaml.safe_load(f)
-                except ScannerError as e:
-                    raise ValueError(str(e))
-
-        return config
-
-    @staticmethod
-    def _get_instance_config(project_config: ProjectConfig, instance_name: str, output: AbstractOutputWriter):
+    def _get_instance_id(instances: List[dict], instance_name: str, output: AbstractOutputWriter):
         if not instance_name:
-            if len(project_config.instances) > 1:
+            if len(instances) > 1:
                 # ask user to choose the instance
                 output.write('Select the instance:\n')
                 with output.prefix('  '):
-                    for i, instance_config in enumerate(project_config.instances):
+                    for i, instance_config in enumerate(instances):
                         output.write('[%d] %s' % (i + 1, instance_config['name']))
                 output.write()
 
@@ -80,18 +51,18 @@ def _get_instance_config(project_config: ProjectConfig, instance_name: str, outp
                 except ValueError:
                     num = 0
 
-                if num < 1 or num > len(project_config.instances):
-                    raise ValueError('The value from 1 to %d was expected.' % len(project_config.instances))
-            else:
-                num = 1
+                if num < 1 or num > len(instances):
+                    raise ValueError('The value from 1 to %d was expected.' % len(instances))
 
-            instance_config = project_config.instances[num - 1]
+                instance_id = num - 1
+            else:
+                instance_id = 0
         else:
-            # get the instance by name
-            instance_configs = filter_list(project_config.instances, 'name', instance_name)
-            if not instance_configs:
+            # get instance ID by name
+            instance_ids = [i for i, instance in enumerate(instances) if instance['name'] == instance_name]
+            if not instance_ids:
                 raise ValueError('Instance "%s" not found in the configuration file' % instance_name)
 
-            instance_config = instance_configs[0]
+            instance_id = instance_ids[0]
 
-        return instance_config
+        return instance_id
diff --git a/spotty/commands/abstract_provider_command.py b/spotty/commands/abstract_provider_command.py
index 67fab3b..844476f 100644
--- a/spotty/commands/abstract_provider_command.py
+++ b/spotty/commands/abstract_provider_command.py
@@ -3,7 +3,6 @@
 import sys
 from spotty.commands.abstract_command import AbstractCommand
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.helpers.commands import add_subparsers
 
 
 class AbstractProviderCommand(AbstractCommand):
@@ -16,6 +15,7 @@ def commands(self) -> list:
         raise NotImplementedError
 
     def configure(self, parser: ArgumentParser):
+        from spotty.cli import add_subparsers
         add_subparsers(parser, self.commands)
 
     def run(self, args: Namespace, output: AbstractOutputWriter):
diff --git a/spotty/commands/aws.py b/spotty/commands/aws.py
index d1260a2..89ddbae 100644
--- a/spotty/commands/aws.py
+++ b/spotty/commands/aws.py
@@ -1,7 +1,5 @@
 from spotty.commands.abstract_provider_command import AbstractProviderCommand
 from spotty.providers.aws.commands.clean_logs import CleanLogsCommand
-from spotty.providers.aws.commands.create_ami import CreateAmiCommand
-from spotty.providers.aws.commands.delete_ami import DeleteAmiCommand
 from spotty.providers.aws.commands.spot_prices import SpotPricesCommand
 
 
@@ -10,8 +8,6 @@ class AwsCommand(AbstractProviderCommand):
     name = 'aws'
     description = 'AWS commands'
     commands = [
-        CreateAmiCommand,
-        DeleteAmiCommand,
         SpotPricesCommand,
         CleanLogsCommand,
     ]
diff --git a/spotty/commands/download.py b/spotty/commands/download.py
index dd08aa9..2e88dca 100644
--- a/spotty/commands/download.py
+++ b/spotty/commands/download.py
@@ -1,7 +1,9 @@
 from argparse import Namespace, ArgumentParser
 from spotty.commands.abstract_config_command import AbstractConfigCommand
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
+from spotty.errors.instance_not_running import InstanceNotRunningError
+from spotty.errors.nothing_to_do import NothingToDoError
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
 
 
 class DownloadCommand(AbstractConfigCommand):
@@ -18,6 +20,10 @@ def configure(self, parser: ArgumentParser):
         parser.add_argument('--dry-run', action='store_true', help='Show files to be downloaded')
 
     def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
+        # check that the instance is started
+        if not instance_manager.is_running():
+            raise InstanceNotRunningError(instance_manager.instance_config.name)
+
         filters = [
             {'exclude': ['*']},
             {'include': args.include}
@@ -25,6 +31,10 @@ def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, outpu
 
         dry_run = args.dry_run
         with output.prefix('[dry-run] ' if dry_run else ''):
-            instance_manager.download(filters, output, dry_run)
+            try:
+                instance_manager.download(filters, output, dry_run)
+            except NothingToDoError as e:
+                output.write(str(e))
+                return
 
         output.write('Done')
diff --git a/spotty/commands/exec.py b/spotty/commands/exec.py
new file mode 100644
index 0000000..566df72
--- /dev/null
+++ b/spotty/commands/exec.py
@@ -0,0 +1,54 @@
+import sys
+from argparse import ArgumentParser, Namespace
+from spotty.commands.abstract_config_command import AbstractConfigCommand
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.deployment.utils.cli import shlex_join
+from spotty.errors.instance_not_running import InstanceNotRunningError
+from spotty.errors.nothing_to_do import NothingToDoError
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
+
+
+class ExecCommand(AbstractConfigCommand):
+
+    name = 'exec'
+    description = 'Execute a command in the container'
+
+    def configure(self, parser: ArgumentParser):
+        super().configure(parser)
+        parser.add_argument('-i', '--interactive', action='store_true', help='Pass STDIN to the container')
+        parser.add_argument('-t', '--tty', action='store_true', help='Allocate a pseudo-TTY')
+        parser.add_argument('-u', '--user', type=str, default=None,
+                            help='Container username or UID (format: <name|uid>[:<group|gid>])')
+        parser.add_argument('--no-sync', action='store_true', help='Don\'t sync the project before running the script')
+
+        # add the "double-dash" argument to the usage message
+        parser.prog = 'spotty exec'
+        parser.usage = parser.format_usage()[7:-1] + ' -- COMMAND [args...]\n'
+        parser.epilog = 'The double dash (--) separates the command that you want to execute inside the container ' \
+                        'from the Spotty arguments.'
+
+    def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
+        # check that the command is provided
+        if not args.custom_args:
+            raise ValueError('Use the double-dash ("--") to split Spotty arguments from the command that should be '
+                             'executed inside the container.')
+
+        # check that the instance is started
+        if not instance_manager.is_running():
+            raise InstanceNotRunningError(instance_manager.instance_config.name)
+
+        # sync the project with the instance
+        if not args.no_sync:
+            try:
+                instance_manager.sync(output)
+            except NothingToDoError:
+                pass
+
+        # generate a "docker exec" command
+        command = shlex_join(args.custom_args)
+        command = instance_manager.container_commands.exec(command, interactive=args.interactive, tty=args.tty,
+                                                           user=args.user)
+
+        # execute the command on the host OS
+        exit_code = instance_manager.exec(command, tty=args.tty)
+        sys.exit(exit_code)
diff --git a/spotty/commands/gcp.py b/spotty/commands/gcp.py
deleted file mode 100644
index e7c82bc..0000000
--- a/spotty/commands/gcp.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from spotty.commands.abstract_provider_command import AbstractProviderCommand
-from spotty.providers.gcp.commands.create_image import CreateImageCommand
-from spotty.providers.gcp.commands.delete_image import DeleteImageCommand
-
-
-class GcpCommand(AbstractProviderCommand):
-
-    name = 'gcp'
-    description = 'GCP commands'
-    commands = [
-        CreateImageCommand,
-        DeleteImageCommand,
-    ]
diff --git a/spotty/commands/run.py b/spotty/commands/run.py
index 39def92..69fe806 100644
--- a/spotty/commands/run.py
+++ b/spotty/commands/run.py
@@ -1,28 +1,37 @@
+import time
 from argparse import ArgumentParser, Namespace
 from spotty.commands.abstract_config_command import AbstractConfigCommand
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.deployment.utils.commands import get_script_command, get_log_command, get_tmux_session_command, get_bash_command
 from spotty.errors.instance_not_running import InstanceNotRunningError
-from spotty.helpers.run import parse_parameters, render_script
-from spotty.helpers.ssh import run_script
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
+from spotty.errors.nothing_to_do import NothingToDoError
+from spotty.deployment.utils.user_scripts import parse_script_parameters, render_script
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
 
 
 class RunCommand(AbstractConfigCommand):
 
     name = 'run'
-    description = 'Run a script from the configuration file inside the Docker container'
+    description = 'Run a custom script from the configuration file inside the container'
 
     def configure(self, parser: ArgumentParser):
         super().configure(parser)
-        parser.add_argument('-s', '--session-name', type=str, default=None, help='tmux session name')
-        parser.add_argument('-S', '--sync', action='store_true', help='Sync the project before running the script')
-        parser.add_argument('-l', '--logging', action='store_true', help='Log the script outputs to the file')
-        parser.add_argument('-r', '--restart', action='store_true',
-                            help='Restart the script (kills previous session if it exists)')
         parser.add_argument('script_name', metavar='SCRIPT_NAME', type=str, help='Script name')
+        parser.add_argument('-u', '--user', type=str, default=None,
+                            help='Container username or UID (format: <name|uid>[:<group|gid>])')
+        parser.add_argument('-s', '--session-name', type=str, default=None, help='tmux session name')
+        parser.add_argument('-l', '--logging', action='store_true', help='Log the script outputs to a file')
         parser.add_argument('-p', '--parameter', metavar='PARAMETER=VALUE', action='append', type=str, default=[],
-                            help='Set the value for a script parameter (you can use this argument multiple times '
-                                 'to set several parameters)')
+                            help='Set a value for the script parameter (format: PARAMETER=VALUE). This '
+                                 'argument can be used multiple times to set several parameters. Parameters can be '
+                                 'used in the script as Mustache variables (for example: {{PARAMETER}}).')
+        parser.add_argument('--no-sync', action='store_true', help='Don\'t sync the project before running the script')
+
+        # add the "double-dash" argument to the usage message
+        parser.prog = 'spotty run'
+        parser.usage = parser.format_usage()[7:-1] + ' [-- args...]\n'
+        parser.epilog = 'The double dash (--) separates custom arguments that you can pass to the script ' \
+                        'from the Spotty arguments.'
 
     def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
         # check that the script exists
@@ -32,7 +41,7 @@ def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, outpu
             raise ValueError('Script "%s" is not defined in the configuration file.' % script_name)
 
         # replace script parameters
-        params = parse_parameters(args.parameter)
+        params = parse_script_parameters(args.parameter)
         script_content = render_script(scripts[script_name], params)
 
         # check that the instance is started
@@ -40,19 +49,30 @@ def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, outpu
             raise InstanceNotRunningError(instance_manager.instance_config.name)
 
         # sync the project with the instance
-        if args.sync:
-            instance_manager.sync(output)
-
-        # tmux session name
-        session_name = args.session_name if args.session_name else 'spotty-script-%s' % script_name
-
-        # run the script on the instance
-        run_script(host=instance_manager.get_ip_address(),
-                   port=instance_manager.ssh_port,
-                   user=instance_manager.ssh_user,
-                   key_path=instance_manager.ssh_key_path,
-                   script_name=script_name,
-                   script_content=script_content,
-                   tmux_session_name=session_name,
-                   restart=args.restart,
-                   logging=args.logging)
+        if not args.no_sync:
+            try:
+                instance_manager.sync(output)
+            except NothingToDoError:
+                pass
+
+        # get a command to run the script with "docker exec"
+        script_command = get_script_command(script_name, script_content, script_args=args.custom_args)
+        command = instance_manager.container_commands.exec(script_command, interactive=True, tty=True,
+                                                           user=args.user)
+
+        # wrap the command with logging
+        if args.logging:
+            log_file_path = instance_manager.instance_config.host_logs_dir + '/run/%s-%d.log' \
+                            % (script_name, time.time())
+            command = get_log_command(command, log_file_path)
+
+        # wrap the command with the tmux session
+        if instance_manager.use_tmux:
+            session_name = args.session_name if args.session_name else 'spotty-script-%s' % script_name
+            default_command = instance_manager.container_commands.exec(get_bash_command(), interactive=True, tty=True,
+                                                                       user=args.user)
+            command = get_tmux_session_command(command, session_name, script_name, default_command=default_command,
+                                               keep_pane=True)
+
+        # execute command on the host OS
+        instance_manager.exec(command)
diff --git a/spotty/commands/sh.py b/spotty/commands/sh.py
new file mode 100644
index 0000000..b564534
--- /dev/null
+++ b/spotty/commands/sh.py
@@ -0,0 +1,54 @@
+from argparse import ArgumentParser, Namespace
+from spotty.commands.abstract_config_command import AbstractConfigCommand
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.deployment.utils.commands import get_bash_command, get_tmux_session_command
+from spotty.errors.instance_not_running import InstanceNotRunningError
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
+
+
+class ShCommand(AbstractConfigCommand):
+
+    name = 'sh'
+    description = 'Get a shell to the container or to the instance itself'
+
+    def configure(self, parser: ArgumentParser):
+        super().configure(parser)
+        parser.add_argument('-u', '--user', type=str, default=None,
+                            help='Container username or UID (format: <name|uid>[:<group|gid>])')
+        parser.add_argument('-H', '--host-os', action='store_true', help='Connect to the host OS instead of the Docker '
+                                                                         'container')
+        parser.add_argument('-s', '--session-name', type=str, default=None, help='tmux session name')
+        parser.add_argument('-l', '--list-sessions', action='store_true', help='List all tmux sessions managed by the '
+                                                                               'instance')
+
+    def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
+        # check that the instance is started
+        if not instance_manager.is_running():
+            raise InstanceNotRunningError(instance_manager.instance_config.name)
+
+        if args.list_sessions:
+            if not instance_manager.use_tmux:
+                raise ValueError('The "%s" provider doesn\'t support tmux.'
+                                 % instance_manager.instance_config.provider_name)
+
+            # a command to list existing tmux session on the host OS
+            command = 'tmux ls; echo ""'
+        else:
+            if args.host_os:
+                # get a command to open a login shell on the host OS
+                session_name = args.session_name if args.session_name else 'spotty-sh-host'
+                shell_command = '$SHELL'
+                command = get_tmux_session_command(shell_command, session_name, keep_pane=False) \
+                    if instance_manager.use_tmux else shell_command
+            else:
+                # get a command to run bash inside the docker container
+                command = instance_manager.container_commands.exec(get_bash_command(), interactive=True, tty=True,
+                                                                   user=args.user)
+
+                # wrap the command with the tmux session
+                if instance_manager.use_tmux:
+                    session_name = args.session_name if args.session_name else 'spotty-sh-container'
+                    command = get_tmux_session_command(command, session_name, default_command=command, keep_pane=False)
+
+        # execute command on the host OS
+        instance_manager.exec(command)
diff --git a/spotty/commands/ssh.py b/spotty/commands/ssh.py
deleted file mode 100644
index de58e9f..0000000
--- a/spotty/commands/ssh.py
+++ /dev/null
@@ -1,44 +0,0 @@
-from argparse import ArgumentParser, Namespace
-import subprocess
-from spotty.commands.abstract_config_command import AbstractConfigCommand
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.helpers.ssh import get_ssh_command
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
-
-
-class SshCommand(AbstractConfigCommand):
-
-    name = 'ssh'
-    description = 'Connect to the running Docker container or to the instance itself'
-
-    def configure(self, parser: ArgumentParser):
-        super().configure(parser)
-        parser.add_argument('-H', '--host-os', action='store_true', help='Connect to the host OS instead of the Docker '
-                                                                         'container')
-        parser.add_argument('-s', '--session-name', type=str, default=None, help='tmux session name')
-        parser.add_argument('-l', '--list-sessions', action='store_true', help='List all tmux sessions managed by the '
-                                                                               'instance')
-
-    def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
-        if args.list_sessions:
-            remote_cmd = ['tmux', 'ls', ';', 'echo', '']
-        else:
-            # tmux session name
-            session_name = args.session_name
-            if not session_name:
-                session_name = 'spotty-ssh-host-os' if args.host_os else 'spotty-ssh-container'
-
-            # a command to connect to the host OS or to the container
-            remote_cmd = ['tmux', 'new', '-s', session_name, '-A']
-            if not args.host_os:
-                # connect to the container or keep the tmux window in case of a failure
-                container_cmd = subprocess.list2cmdline(['sudo', '/tmp/spotty/instance/scripts/container_bash.sh'])
-                tmux_cmd = '%s || tmux set remain-on-exit on' % container_cmd
-                remote_cmd += [tmux_cmd]
-
-        remote_cmd = subprocess.list2cmdline(remote_cmd)
-
-        # connect to the instance
-        ssh_command = get_ssh_command(instance_manager.get_ip_address(), instance_manager.ssh_port,
-                                      instance_manager.ssh_user, instance_manager.ssh_key_path, remote_cmd)
-        subprocess.call(ssh_command)
diff --git a/spotty/commands/start.py b/spotty/commands/start.py
index f7542cf..af2a79c 100644
--- a/spotty/commands/start.py
+++ b/spotty/commands/start.py
@@ -1,32 +1,52 @@
 from argparse import Namespace, ArgumentParser
 from spotty.commands.abstract_config_command import AbstractConfigCommand
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
+from spotty.errors.instance_not_running import InstanceNotRunningError
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
 
 
 class StartCommand(AbstractConfigCommand):
 
     name = 'start'
-    description = 'Run a spot instance, sync the project and start the Docker container'
+    description = 'Start an instance with a container'
 
     def configure(self, parser: ArgumentParser):
         super().configure(parser)
+        parser.add_argument('-C', '--container', action='store_true', help='Starts or restarts container on the '
+                                                                           'running instance')
         parser.add_argument('--dry-run', action='store_true', help='Displays the steps that would be performed '
                                                                    'using the specified command without actually '
                                                                    'running them')
 
     def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
-        # start the instance
         dry_run = args.dry_run
-        with output.prefix('[dry-run] ' if dry_run else ''):
-            instance_manager.start(output, dry_run)
-
-        if not dry_run:
-            instance_name = ''
-            if len(instance_manager.project_config.instances) > 1:
-                instance_name = ' ' + instance_manager.instance_config.name
-
-            output.write('\nThe instance was successfully started.\n'
-                         '\n%s\n'
-                         '\nUse the "spotty ssh%s" command to connect to the Docker container.\n'
-                         % (instance_manager.get_status_text(), instance_name))
+
+        if args.container:
+            # check that the instance is started
+            if not instance_manager.is_running():
+                raise InstanceNotRunningError(instance_manager.instance_config.name)
+
+            # start a container on the running instance
+            instance_manager.start_container(output, dry_run=dry_run)
+
+            if not dry_run:
+                instance_name = ''
+                if len(instance_manager.project_config.instances) > 1:
+                    instance_name = ' ' + instance_manager.instance_config.name
+
+                output.write('\nContainer was successfully started.\n'
+                             'Use the "spotty sh%s" command to connect to the container.\n'
+                             % instance_name)
+        else:
+            # start the instance
+            with output.prefix('[dry-run] ' if dry_run else ''):
+                instance_manager.start(output, dry_run)
+
+            if not dry_run:
+                instance_name = ''
+                if len(instance_manager.project_config.instances) > 1:
+                    instance_name = ' ' + instance_manager.instance_config.name
+
+                output.write('\n%s\n'
+                             '\nUse the "spotty sh%s" command to connect to the container.\n'
+                             % (instance_manager.get_status_text(), instance_name))
diff --git a/spotty/commands/status.py b/spotty/commands/status.py
index bce2867..dd8b03c 100644
--- a/spotty/commands/status.py
+++ b/spotty/commands/status.py
@@ -1,7 +1,7 @@
 from argparse import Namespace
 from spotty.commands.abstract_config_command import AbstractConfigCommand
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
 
 
 class StatusCommand(AbstractConfigCommand):
diff --git a/spotty/commands/stop.py b/spotty/commands/stop.py
index 18bd54d..26ca803 100644
--- a/spotty/commands/stop.py
+++ b/spotty/commands/stop.py
@@ -1,7 +1,7 @@
 from argparse import Namespace
 from spotty.commands.abstract_config_command import AbstractConfigCommand
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
 
 
 class StopCommand(AbstractConfigCommand):
@@ -9,11 +9,13 @@ class StopCommand(AbstractConfigCommand):
     name = 'stop'
     description = 'Terminate running instance and apply deletion policies for the volumes'
 
-    def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
+    # TODO: the "spotty start" command should restart the instance and the container if the instance was shutdown
+    # def configure(self, parser: ArgumentParser):
+    #     super().configure(parser)
+    #     parser.add_argument('-s', '--shutdown', action='store_true',
+    #                         help='Shutdown the instance without terminating it. Deletion policies for the volumes '
+    #                              'won\'t be applied.')
 
-        instance_manager.stop(output)
+    def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
 
-        output.write('\n'
-                     '----------------------------------\n'
-                     'Instance was successfully deleted.\n'
-                     '----------------------------------')
+        instance_manager.stop(only_shutdown=False, output=output)
diff --git a/spotty/commands/sync.py b/spotty/commands/sync.py
index eded90d..3ae8f98 100644
--- a/spotty/commands/sync.py
+++ b/spotty/commands/sync.py
@@ -1,7 +1,9 @@
 from argparse import Namespace, ArgumentParser
 from spotty.commands.abstract_config_command import AbstractConfigCommand
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
+from spotty.errors.instance_not_running import InstanceNotRunningError
+from spotty.errors.nothing_to_do import NothingToDoError
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
 
 
 class SyncCommand(AbstractConfigCommand):
@@ -14,8 +16,16 @@ def configure(self, parser: ArgumentParser):
         parser.add_argument('--dry-run', action='store_true', help='Show files to be synced')
 
     def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
+        # check that the instance is started
+        if not instance_manager.is_running():
+            raise InstanceNotRunningError(instance_manager.instance_config.name)
+
         dry_run = args.dry_run
         with output.prefix('[dry-run] ' if dry_run else ''):
-            instance_manager.sync(output, dry_run)
+            try:
+                instance_manager.sync(output, dry_run)
+            except NothingToDoError as e:
+                output.write(str(e))
+                return
 
         output.write('Done')
diff --git a/spotty/commands/writers/abstract_output_writrer.py b/spotty/commands/writers/abstract_output_writrer.py
index 11c4b0a..723587b 100644
--- a/spotty/commands/writers/abstract_output_writrer.py
+++ b/spotty/commands/writers/abstract_output_writrer.py
@@ -6,14 +6,18 @@ class AbstractOutputWriter(ABC):
 
     def __init__(self):
         self._prefix = ''
+        self._ignore_prefix = False
 
     @abstractmethod
-    def _write(self, msg: str):
+    def _write(self, msg: str, newline: bool = True):
         raise NotImplementedError
 
-    def write(self, msg: str = ''):
-        msg = '\n'.join([self._prefix + line for line in msg.split('\n')])
-        self._write(msg)
+    def write(self, msg: str = '', newline: bool = True):
+        if not self._ignore_prefix:
+            msg = '\n'.join([self._prefix + line for line in msg.split('\n')])
+
+        self._write(msg, newline=newline)
+        self._ignore_prefix = not newline
 
     @contextmanager
     def prefix(self, prefix):
diff --git a/spotty/commands/writers/null_output_writrer.py b/spotty/commands/writers/null_output_writrer.py
index 2124505..0a6696c 100644
--- a/spotty/commands/writers/null_output_writrer.py
+++ b/spotty/commands/writers/null_output_writrer.py
@@ -3,6 +3,6 @@
 
 class NullOutputWriter(AbstractOutputWriter):
 
-    def _write(self, msg: str):
+    def _write(self, msg: str, newline: bool = True):
         """Does nothing."""
         pass
diff --git a/spotty/commands/writers/output_writrer.py b/spotty/commands/writers/output_writrer.py
index 0eccd2f..33731dd 100644
--- a/spotty/commands/writers/output_writrer.py
+++ b/spotty/commands/writers/output_writrer.py
@@ -3,6 +3,6 @@
 
 class OutputWriter(AbstractOutputWriter):
 
-    def _write(self, msg: str):
+    def _write(self, msg: str, newline: bool = True):
         """Prints messages to STDOUT."""
-        print(msg, flush=True)
+        print(msg, end=('\n' if newline else ''), flush=True)
diff --git a/spotty/config/abstract_instance_config.py b/spotty/config/abstract_instance_config.py
index adc6094..27cc2b7 100644
--- a/spotty/config/abstract_instance_config.py
+++ b/spotty/config/abstract_instance_config.py
@@ -1,12 +1,67 @@
-from abc import ABC
+import os
+from abc import ABC, abstractmethod
+from collections import OrderedDict, namedtuple
+from typing import List
+from spotty.config.container_config import ContainerConfig
+from spotty.config.project_config import ProjectConfig
+from spotty.config.tmp_dir_volume import TmpDirVolume
+from spotty.config.validation import DEFAULT_CONTAINER_NAME, is_subdir
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.deployment.abstract_cloud_instance.file_structure import INSTANCE_SPOTTY_TMP_DIR
+from spotty.utils import filter_list
+
+
+VolumeMount = namedtuple('VolumeMount', ['name', 'host_path', 'mount_path', 'mode', 'hidden'])
 
 
 class AbstractInstanceConfig(ABC):
 
-    def __init__(self, config: dict):
-        self._name = config['name']
-        self._provider_name = config['provider']
-        self._params = config['parameters']
+    def __init__(self, instance_config: dict, project_config: ProjectConfig):
+        self._project_config = project_config
+
+        # set instance parameters
+        self._name = instance_config['name']
+        self._provider_name = instance_config['provider']
+        self._params = self._validate_instance_params(instance_config['parameters'])
+
+        # get container config
+        container_configs = filter_list(project_config.containers, 'name', self.container_name)
+        if not container_configs:
+            raise ValueError('Container configuration for the instance not found.')
+
+        self._container_config = ContainerConfig(container_configs[0])
+
+        # get volumes
+        self._volumes = self._get_volumes()
+
+        # get container volume mounts
+        self._volume_mounts = self._get_volume_mounts(self._volumes)
+
+        # get the host project directory
+        self._host_project_dir = self._get_host_project_dir(self._volume_mounts)
+
+    @abstractmethod
+    def _validate_instance_params(self, params: dict) -> dict:
+        """Validates instance parameters and fill missing ones with the default values."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def _get_instance_volumes(self) -> List[AbstractInstanceVolume]:
+        """Returns specific to the provider volumes that should be mounted on the host OS."""
+        raise NotImplementedError
+
+    @property
+    def project_config(self) -> ProjectConfig:
+        return self._project_config
+
+    @property
+    def container_config(self) -> ContainerConfig:
+        return self._container_config
+
+    @property
+    @abstractmethod
+    def user(self) -> str:
+        raise NotImplementedError
 
     @property
     def name(self) -> str:
@@ -19,9 +74,13 @@ def provider_name(self):
         return self._provider_name
 
     @property
-    def volumes(self) -> list:
-        """List of volume configs."""
-        return self._params['volumes']
+    def container_name(self) -> str:
+        return self._params['containerName'] if self._params['containerName'] else DEFAULT_CONTAINER_NAME
+
+    @property
+    def full_container_name(self) -> str:
+        """A container name that is used in the "docker run" command."""
+        return ('spotty-%s-%s-%s' % (self.project_config.project_name, self.name, self.container_name)).lower()
 
     @property
     def docker_data_root(self) -> str:
@@ -37,3 +96,103 @@ def local_ssh_port(self) -> int:
     def commands(self) -> str:
         """Commands that should be run once an instance is started."""
         return self._params['commands']
+
+    @property
+    def host_project_dir(self):
+        """Project directory on the host OS."""
+        return self._host_project_dir
+
+    @property
+    def volumes(self) -> List[AbstractInstanceVolume]:
+        return self._volumes
+
+    @property
+    def volume_mounts(self) -> List[VolumeMount]:
+        return self._volume_mounts
+
+    @property
+    def dockerfile_path(self):
+        """Dockerfile path on the host OS."""
+        dockerfile_path = self.container_config.file
+        if dockerfile_path:
+            dockerfile_path = self.host_project_dir + '/' + dockerfile_path
+
+        return dockerfile_path
+
+    @property
+    def docker_context_path(self):
+        """Docker build's context path on the host OS."""
+        dockerfile_path = self.dockerfile_path
+        if not dockerfile_path:
+            return ''
+
+        return os.path.dirname(dockerfile_path)
+
+    @property
+    def host_container_dir(self):
+        """A temporary directory on the host OS that contains container-related files and directories."""
+        return '%s/containers/%s' % (INSTANCE_SPOTTY_TMP_DIR, self.full_container_name)
+
+    @property
+    def host_logs_dir(self):
+        """A directory mainly for the "spotty run" command logs."""
+        return self.host_container_dir + '/logs'
+
+    @property
+    def host_volumes_dir(self):
+        """A directory with temporary volumes. If there is a Volume Mount in the configuration file
+        that doesn't have a corresponding instance volume, a temporary directory will be created
+        and attached to the container.
+        """
+        return self.host_container_dir + '/volumes'
+
+    def _get_volumes(self) -> List[AbstractInstanceVolume]:
+        """Returns volumes that should be mounted on the host OS."""
+        volumes = self._get_instance_volumes()
+
+        # create temporary volumes for the volume mounts that don't have corresponding
+        # volumes in the instance configuration
+        instance_volume_names = set(volume.name for volume in volumes)
+        for container_volume in self.container_config.volume_mounts:
+            if container_volume['name'] not in instance_volume_names:
+                volumes.append(TmpDirVolume(volume_config={
+                    'name': container_volume['name'],
+                    'parameters': {'path': '%s/%s' % (self.host_volumes_dir, container_volume['name'])}
+                }, project_name=self.project_config.project_name, instance_name=self.name))
+
+        return volumes
+
+    def _get_volume_mounts(self, volumes: List[AbstractInstanceVolume]) \
+            -> List[VolumeMount]:
+        """Returns container volume mounts and a path to the project directory on the host OS."""
+        # get mount directories for the volumes
+        host_paths = OrderedDict([(volume.name, volume.host_path) for volume in volumes])
+
+        # get container volumes mapping
+        volume_mounts = []
+        for container_volume in self.container_config.volume_mounts:
+            volume_mounts.append(VolumeMount(
+                name=container_volume['name'],
+                host_path=host_paths[container_volume['name']],
+                mount_path=container_volume['mountPath'],
+                mode='rw',
+                hidden=False,
+            ))
+
+        return volume_mounts
+
+    def _get_host_project_dir(self, volume_mounts: List[VolumeMount]) -> str:
+        """Returns the host project directory."""
+        host_project_dir = None
+        for volume_mount in sorted(volume_mounts, key=lambda x: len(x.mount_path), reverse=True):
+            if is_subdir(self.container_config.project_dir, volume_mount.mount_path):
+                # the project directory is a subdirectory of a Volume Mount directory
+                project_subdir = os.path.relpath(self.container_config.project_dir, volume_mount.mount_path)
+                host_project_dir = os.path.normpath(volume_mount.host_path + '/' + project_subdir)
+                break
+
+        # this should not be the case as the volume mount for the project directory should be added automatically
+        # if it doesn't exist in the configuration
+        assert host_project_dir is not None, 'A volume mount that contains the project directory not found.'
+
+        return host_project_dir
diff --git a/spotty/deployment/abstract_instance_volume.py b/spotty/config/abstract_instance_volume.py
similarity index 61%
rename from spotty/deployment/abstract_instance_volume.py
rename to spotty/config/abstract_instance_volume.py
index f81cf80..b625a9f 100644
--- a/spotty/deployment/abstract_instance_volume.py
+++ b/spotty/config/abstract_instance_volume.py
@@ -3,16 +3,26 @@
 
 class AbstractInstanceVolume(ABC):
 
-    @property
+    def __init__(self, volume_config: dict, project_name: str, instance_name: str):
+        self._name = volume_config['name']
+        self._params = self._validate_volume_parameters(volume_config['parameters'])
+
+        self._project_name = project_name
+        self._instance_name = instance_name
+
     @abstractmethod
+    def _validate_volume_parameters(self, params: dict) -> dict:
+        raise NotImplementedError
+
+    @property
     def name(self) -> str:
         """Unique name of the volume that will be used for the deployment."""
-        raise NotImplementedError
+        return self._name
 
     @property
     @abstractmethod
-    def mount_dir(self) -> str:
-        """A directory where the volume should be mounted on the host OS."""
+    def host_path(self) -> str:
+        """A path on the host OS that will be mounted to the container."""
         raise NotImplementedError
 
     @property
diff --git a/spotty/config/config_utils.py b/spotty/config/config_utils.py
new file mode 100644
index 0000000..7d8785b
--- /dev/null
+++ b/spotty/config/config_utils.py
@@ -0,0 +1,129 @@
+import os
+from collections import namedtuple
+import yaml
+from spotty.config.project_config import ProjectConfig
+from spotty.config.validation import DEFAULT_CONTAINER_NAME
+
+
+DEFAULT_CONFIG_FILENAME = 'spotty.yaml'
+OVERRIDE_CONFIG_FILENAME = 'spotty.override.yaml'
+
+
+def load_config(config_path: str = None) -> ProjectConfig:
+    # get project directory
+    if not config_path:
+        config_path = DEFAULT_CONFIG_FILENAME
+
+    if os.path.isabs(config_path):
+        config_abs_path = config_path
+    else:
+        config_abs_path = os.path.abspath(os.path.join(os.getcwd(), config_path))
+
+    if not os.path.exists(config_abs_path):
+        raise ValueError('Configuration file "%s" not found.' % config_path)
+
+    # get the project directory
+    project_dir = os.path.dirname(config_abs_path)
+
+    # read the config
+    config = _read_yaml(config_abs_path)
+
+    # update the config if an override config exists
+    if os.path.basename(config_abs_path) == DEFAULT_CONFIG_FILENAME:
+        override_config_abs_path = os.path.join(project_dir, OVERRIDE_CONFIG_FILENAME)
+        if os.path.isfile(override_config_abs_path):
+            override_config = _read_yaml(override_config_abs_path)
+            config = _merge_configs(config, override_config)
+
+    # get project configuration
+    project_config = ProjectConfig(config, project_dir)
+
+    return project_config
+
+
+def _read_yaml(file_path: str):
+    """Returns content of the YAML file."""
+    with open(file_path, 'r') as f:
+        res = yaml.safe_load(f)
+
+    return res
+
+
+def _merge_configs(orig_config, override_config):
+    """Merges original config with the override config."""
+
+    MergeRule = namedtuple('MergeRule', ['key', 'merge_key', 'default_value', 'has_default_value'])
+
+    merge_rules = [MergeRule(
+        key='containers',
+        merge_key='name',
+        default_value=DEFAULT_CONTAINER_NAME,
+        has_default_value=True,
+    ), MergeRule(
+        key='instances',
+        merge_key='name',
+        default_value=None,
+        has_default_value=False,
+    )]
+
+    # validate and merge lists by keys
+    for rule in merge_rules:
+        if override_config and (rule.key in orig_config) and (rule.key in override_config):
+            if not isinstance(orig_config[rule.key], list):
+                raise ValueError('The "%s" key in the config must be a list.' % rule.key)
+
+            if not isinstance(override_config[rule.key], list):
+                raise ValueError('The "%s" key in the override config must be a list.' % rule.key)
+
+            # convert lists to dictionaries
+            dicts_to_merge = []
+            for list_to_merge in [orig_config[rule.key], override_config[rule.key]]:
+                dict_to_merge = {}
+                for item in list_to_merge:
+                    if not isinstance(item, dict):
+                        raise ValueError('Each item of the "%s" list must be a dictionary.' % rule.key)
+
+                    if rule.merge_key in item:
+                        merge_value = item[rule.merge_key]
+                    elif rule.has_default_value:
+                        merge_value = rule.default_value
+                    else:
+                        raise ValueError('Each item of the "%s" list must contain the "%s" field.'
+                                         % (rule.key, rule.merge_key))
+
+                    if merge_value in dict_to_merge:
+                        raise ValueError('Each item of the "%s" list must have a unique "%s" value.'
+                                         % (rule.key, rule.merge_key))
+
+                    dict_to_merge[merge_value] = item
+
+                dicts_to_merge.append(dict_to_merge)
+
+            # merge lists
+            merged_dict = _update_dict(*dicts_to_merge)
+            orig_config[rule.key] = [{**item, rule.merge_key: key} for key, item in merged_dict.items()]
+            del override_config[rule.key]
+
+    # merge the rest of the override config
+    config = _update_dict(orig_config, override_config)
+
+    return config
+
+
+def _update_dict(d, u):
+    if not isinstance(u, dict):
+        return d
+
+    if not isinstance(d, dict):
+        return u
+
+    for k, v in u.items():
+        if isinstance(d, dict):
+            if isinstance(v, dict):
+                d[k] = _update_dict(d.get(k, {}), v)
+            else:
+                d[k] = u[k]
+        else:
+            d = {k: u[k]}
+
+    return d
diff --git a/spotty/config/container_config.py b/spotty/config/container_config.py
index d32c5cc..b98cfea 100644
--- a/spotty/config/container_config.py
+++ b/spotty/config/container_config.py
@@ -1,37 +1,46 @@
 from typing import List
+from spotty.config.validation import is_subdir
+
+
+PROJECT_VOLUME_MOUNT_NAME = '.project'
 
 
 class ContainerConfig(object):
 
     def __init__(self, container_config: dict):
         self._config = container_config
+        self._volume_mounts = self._get_volume_mounts()
+
+    @property
+    def name(self) -> str:
+        return self._config['name']
 
     @property
-    def project_dir(self):
+    def project_dir(self) -> str:
         return self._config['projectDir']
 
     @property
-    def image(self):
+    def image(self) -> str:
         return self._config['image']
 
     @property
-    def file(self):
+    def file(self) -> str:
         return self._config['file']
 
     @property
-    def volume_mounts(self):
-        return self._config['volumeMounts']
+    def run_as_host_user(self) -> str:
+        return self._config['runAsHostUser']
 
     @property
-    def ports(self) -> List[int]:
-        return list(set(self._config['ports']))
+    def volume_mounts(self) -> list:
+        return self._volume_mounts
 
     @property
-    def commands(self):
+    def commands(self) -> str:
         return self._config['commands']
 
     @property
-    def working_dir(self):
+    def working_dir(self) -> str:
         """Working directory for the Docker container."""
         working_dir = self._config['workingDir']
         if not working_dir:
@@ -40,5 +49,39 @@ def working_dir(self):
         return working_dir
 
     @property
-    def runtime_parameters(self):
+    def env(self) -> dict:
+        return self._config['env']
+
+    @property
+    def host_network(self) -> bool:
+        return self._config['hostNetwork']
+
+    @property
+    def ports(self) -> List[dict]:
+        return self._config['ports']
+
+    @property
+    def runtime_parameters(self) -> list:
         return self._config['runtimeParameters']
+
+    def _get_volume_mounts(self):
+        """Returns container volume mounts from the configuration and
+        adds the project volume mount if necessary."""
+
+        volume_mounts = self._config['volumeMounts']
+
+        # check if the project directory is a sub-directory of one of the volume mounts
+        project_has_volume = False
+        for volume_mount in volume_mounts:
+            if is_subdir(self.project_dir, volume_mount['mountPath']):
+                project_has_volume = True
+                break
+
+        # if it's not, then add new volume mount
+        if not project_has_volume:
+            volume_mounts.insert(0, {
+                'name': PROJECT_VOLUME_MOUNT_NAME,
+                'mountPath': self.project_dir,
+            })
+
+        return volume_mounts
diff --git a/spotty/config/host_path_volume.py b/spotty/config/host_path_volume.py
new file mode 100644
index 0000000..371f4a8
--- /dev/null
+++ b/spotty/config/host_path_volume.py
@@ -0,0 +1,27 @@
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.config.validation import validate_host_path_volume_parameters
+
+
+class HostPathVolume(AbstractInstanceVolume):
+
+    TYPE_NAME = 'HostPath'
+
+    def _validate_volume_parameters(self, params: dict) -> dict:
+        return validate_host_path_volume_parameters(params)
+
+    @property
+    def title(self):
+        return 'HostPath volume'
+
+    @property
+    def name(self):
+        return self._name
+
+    @property
+    def deletion_policy_title(self) -> str:
+        return ''
+
+    @property
+    def host_path(self) -> str:
+        """A path on the host OS that will be mounted to the container."""
+        return self._params['path']
diff --git a/spotty/config/project_config.py b/spotty/config/project_config.py
index 914d0fe..a8b7780 100644
--- a/spotty/config/project_config.py
+++ b/spotty/config/project_config.py
@@ -1,17 +1,14 @@
-from spotty.config.container_config import ContainerConfig
 from spotty.config.validation import validate_basic_config
-from spotty.utils import filter_list
 
 
 class ProjectConfig(object):
 
     def __init__(self, config: dict, project_dir: str):
         # validate the config
-        config = validate_basic_config(config, project_dir)
+        config = validate_basic_config(config)
 
         self._project_dir = project_dir
         self._config = config
-        self._container = ContainerConfig(config['container'])
 
     @property
     def project_dir(self) -> str:
@@ -26,13 +23,13 @@ def sync_filters(self) -> list:
         return self._config['project']['syncFilters']
 
     @property
-    def container(self) -> ContainerConfig:
-        return self._container
-
-    @property
-    def scripts(self) -> dict:
-        return self._config['scripts']
+    def containers(self) -> list:
+        return self._config['containers']
 
     @property
     def instances(self) -> list:
         return self._config['instances']
+
+    @property
+    def scripts(self) -> dict:
+        return self._config['scripts']
diff --git a/spotty/config/tmp_dir_volume.py b/spotty/config/tmp_dir_volume.py
new file mode 100644
index 0000000..2ed9bb8
--- /dev/null
+++ b/spotty/config/tmp_dir_volume.py
@@ -0,0 +1,12 @@
+from spotty.config.host_path_volume import HostPathVolume
+
+
+class TmpDirVolume(HostPathVolume):
+
+    @property
+    def title(self):
+        return 'temporary directory'
+
+    @property
+    def deletion_policy_title(self) -> str:
+        return ''
diff --git a/spotty/config/validation.py b/spotty/config/validation.py
index 29ca37a..e43c543 100644
--- a/spotty/config/validation.py
+++ b/spotty/config/validation.py
@@ -1,84 +1,104 @@
-import logging
 import os
 from typing import List
-from schema import Schema, And, Use, Optional, Or, Regex, SchemaError
+from schema import Schema, And, Use, Optional, Or, Regex, Hook, SchemaError, SchemaForbiddenKeyError
 
 
-def validate_basic_config(data, project_dir):
-    is_old_config = 'instance' in data and 'container' not in data
-    if is_old_config:
-        logging.warning('The format of the configuration file that you\'re using is deprecated and won\'t be '
-                        'supported in the future versions of Spotty.')
+DEFAULT_CONTAINER_NAME = 'default'
 
-        data = convert_old_config(validate_old_config(data, project_dir))
+
+def validate_basic_config(data):
+
+    container = And(
+        {
+            Optional('name', default=DEFAULT_CONTAINER_NAME): And(str, Regex(r'^[\w-]+$')),
+            'projectDir': And(str,
+                              And(os.path.isabs,
+                                  error='Use an absolute path when specifying the project directory'),
+                              Use(lambda x: x.rstrip('/'))
+                              ),
+            Optional('image', default=''): And(str, len),
+            Optional('file', default=''): And(str,  # TODO: a proper regex that the filename is valid
+                                              Regex(r'^[\w\.\/@-]*$',
+                                                    error='Invalid name for a Dockerfile'),
+                                              And(lambda x: not x.endswith('/'),
+                                                  error='Invalid name for a Dockerfile'),
+                                              And(lambda x: not os.path.isabs(x),
+                                                  error='Path to the Dockerfile should be relative to the '
+                                                        'project\'s root directory.'),
+                                              ),
+            Optional('runAsHostUser', default=False): bool,
+            Optional('volumeMounts', default=[]): (And(
+                [{
+                    'name': And(Or(int, str), Use(str), Regex(r'^[\w-]+$')),
+                    'mountPath': And(
+                        str,
+                        And(os.path.isabs, error='Use an absolute path when specifying a mount directory'),
+                        Use(lambda x: x.rstrip('/')),
+                    ),
+                }],
+                And(lambda x: is_unique_value(x, 'name'),
+                    error='Each volume mount must have a unique name.'),
+                And(lambda x: not has_prefix([(volume['mountPath'] + '/') for volume in x]),
+                    error='Volume mount paths cannot be prefixes for each other.'),
+            )),
+            Optional('workingDir', default=''): And(str,
+                                                    And(os.path.isabs,
+                                                        error='Use an absolute path when specifying a '
+                                                              'working directory'),
+                                                    Use(lambda x: x.rstrip('/'))
+                                                    ),
+            Optional('env', default={}): {
+                And(str, Regex(r'^[a-zA-Z_]+[a-zA-Z0-9_]*$')): str,
+            },
+            Optional('hostNetwork', default=False): bool,
+            Optional('ports', default=[]): [{
+                'containerPort': And(int, lambda x: 0 < x < 65536),
+                Optional('hostPort', default=None): And(int, lambda x: 0 < x < 65536),
+            }],
+            Optional('commands', default=''): str,
+            # TODO: allow to use only certain runtime parameters
+            Optional('runtimeParameters', default=[]): And([str], Use(lambda x: [p.strip() for p in x])),
+        },
+        And(lambda x: x['image'] or x['file'], error='Either "image" or "file" should be specified.'),
+        And(lambda x: not (x['image'] and x['file']), error='"image" and "file" cannot be specified together.'),
+        And(lambda x: not (x['hostNetwork'] and x['ports']),
+            error='Published ports and the host network mode cannot be used together.'),
+    )
 
     schema = Schema({
         'project': {
             'name': And(str, Regex(r'^[a-zA-Z0-9][a-zA-Z0-9-]{,26}[a-zA-Z0-9]$')),
-            Optional('syncFilters', default=[]): [And(
-                {
-                    Optional('exclude'): [And(str, len)],
-                    Optional('include'): [And(str, len)],
-                },
-                And(lambda x: x, error='Either "exclude" or "include" filter should be specified.'),
-                And(lambda x: not ('exclude' in x and 'include' in x), error='"exclude" and "include" filters should '
-                                                                             'be specified as different list items.'),
-            )]
+            Optional('syncFilters', default=[]): And(
+                [And(
+                    {
+                        Optional('exclude'): [And(str, len, And(lambda x: '**' not in x,
+                                                                error='Use single asterisks ("*") in sync filters'))],
+                        Optional('include'): [And(str, len, And(lambda x: '**' not in x,
+                                                                error='Use single asterisks ("*") in sync filters'))],
+                    },
+                    And(lambda x: x, error='Either "exclude" or "include" filter should be specified.'),
+                    And(lambda x: not ('exclude' in x and 'include' in x),
+                        error='"exclude" and "include" filters should be specified as different list items.'),
+                )],
+                error='"project.syncFilters" field must be a list.',
+            )
         },
-        'container': And(
-            {
-                'projectDir': And(str,
-                                  And(os.path.isabs,
-                                      error='Use an absolute path when specifying the project directory'),
-                                  Use(lambda x: x.rstrip('/'))
-                                  ),
-                Optional('image', default=''): And(str, len),
-                Optional('file', default=''): And(str,  # TODO: a proper regex that the filename is valid
-                                                  Regex(r'^[\w\.\/@-]*$',
-                                                        error='Invalid name for a Dockerfile'),
-                                                  And(lambda x: not x.endswith('/'),
-                                                      error='Invalid name for a Dockerfile'),
-                                                  And(lambda x: not os.path.isabs(x),
-                                                      error='Path to the Dockerfile should be relative to the '
-                                                            'project\'s root directory.'),
-                                                  And(lambda x: os.path.isfile(os.path.join(project_dir, x)),
-                                                      error='Dockerfile not found.'),
-                                                  ),
-                Optional('volumeMounts', default=[]): (And(
-                    [{
-                        'name': And(Or(int, str), Use(str), Regex(r'^[\w-]+$')),
-                        'mountPath': And(
-                            str,
-                            And(os.path.isabs, error='Use an absolute path when specifying a mount directory'),
-                            Use(lambda x: x.rstrip('/')),
-                        ),
-                    }],
-                    And(lambda x: is_unique_value(x, 'name'),
-                        error='Each volume mount must have a unique name.'),
-                    And(lambda x: not has_prefix([(volume['mountPath'] + '/') for volume in x]),
-                        error='Volume mount paths cannot be prefixes for each other.'),
-                )),
-                Optional('workingDir', default=''): And(str,
-                                                        And(os.path.isabs,
-                                                            error='Use an absolute path when specifying a '
-                                                                  'working directory'),
-                                                        Use(lambda x: x.rstrip('/'))
-                                                        ),
-                Optional('commands', default=''): str,
-                Optional('ports', default=[]): [And(int, lambda x: 0 <= x <= 65535)],
-                Optional('runtimeParameters', default=[]): [str],
-            },
-            And(lambda x: x['image'] or x['file'], error='Either "image" or "file" should be specified.'),
-            And(lambda x: not (x['image'] and x['file']), error='"image" and "file" cannot be specified together.'),
+        WrongKey('container', error='Use "containers" field instead of "container".'): object,
+        Optional('containers', default=[]): And(
+            [container],
+            And(lambda x: is_unique_value(x, 'name'), error='Each container must have a unique name.'),
+            error='"containers" field must be a list.',
         ),
+        WrongKey('instance', error='Use "instances" field instead of "instance".'): object,
         'instances': And(
             [{
                 'name': And(Or(int, str), Use(str), Regex(r'^[\w-]+$')),
                 'provider': str,
-                'parameters': And({
+                Optional('parameters', default={}): {
                     And(str, Regex(r'^[\w]+$')): object,
-                }, error='Instance parameters are not specified')
+                }
             }],
+            And(lambda x: len(x), error='At least one instance must be specified in the configuration file.'),
             And(lambda x: is_unique_value(x, 'name'), error='Each instance must have a unique name.'),
         ),
         Optional('scripts', default={}): {
@@ -89,6 +109,19 @@ def validate_basic_config(data, project_dir):
     return validate_config(schema, data)
 
 
+def validate_host_path_volume_parameters(params: dict):
+    schema = Schema({
+        'path': And(
+            str,
+            Use(os.path.expanduser),
+            And(os.path.isabs, error='Use absolute path in the "path" parameter'),
+            Use(lambda x: x.rstrip('/')),
+        ),
+    })
+
+    return validate_config(schema, params)
+
+
 def get_instance_parameters_schema(instance_parameters: dict, default_volume_type: str,
                                    instance_checks: list = None, volumes_checks: list = None):
     if not instance_checks:
@@ -100,6 +133,7 @@ def get_instance_parameters_schema(instance_parameters: dict, default_volume_typ
     schema = Schema(And(
         {
             **instance_parameters,
+            Optional('containerName', default=None): And(str, Regex(r'^[\w-]+$')),
             Optional('dockerDataRoot', default=''): And(
                 str,
                 And(os.path.isabs, error='Use an absolute path when specifying a Docker data root directory'),
@@ -110,21 +144,13 @@ def get_instance_parameters_schema(instance_parameters: dict, default_volume_typ
                     'name': And(Or(int, str), Use(str), Regex(r'^[\w-]+$')),
                     Optional('type', default=default_volume_type): str,
                     Optional('parameters', default={}): {
-                        Optional('mountDir', default=''): And(
-                            str,
-                            And(os.path.isabs, error='Use absolute paths for mount directories'),
-                            Use(lambda x: x.rstrip('/'))
-                        ),
                         And(str, Regex(r'^[\w]+$')): object,
                     },
                 }],
                 And(lambda x: is_unique_value(x, 'name'), error='Each instance volume must have a unique name.'),
-                And(lambda x: not has_prefix([(volume['parameters']['mountDir'] + '/') for volume in x
-                                              if volume['parameters']['mountDir']]),
-                    error='Mount directories cannot be prefixes for each other.'),
                 *volumes_checks,
             ),
-            Optional('localSshPort', default=None): Or(None, And(int, lambda x: 0 <= x <= 65535)),
+            Optional('localSshPort', default=None): Or(None, And(int, lambda x: 0 < x < 65536)),
             Optional('commands', default=''): str,
         },
         And(lambda x: not x['dockerDataRoot'] or any([True for v in x['volumes'] if v['parameters']['mountDir'] and
@@ -136,164 +162,6 @@ def get_instance_parameters_schema(instance_parameters: dict, default_volume_typ
     return schema
 
 
-def validate_old_config(data, project_dir):
-    schema = Schema({
-        'project': {
-            'name': And(str, Regex(r'^[a-zA-Z0-9][a-zA-Z0-9-]{,26}[a-zA-Z0-9]$')),
-            'remoteDir': And(str,
-                             And(os.path.isabs,
-                                 error='Use an absolute path when specifying a remote directory'),
-                             Use(lambda x: x.rstrip('/'))
-                             ),
-            Optional('syncFilters', default=[]): [And(
-                {
-                    Optional('exclude'): [And(str, len)],
-                    Optional('include'): [And(str, len)],
-                },
-                And(lambda x: x, error='Either "exclude" or "include" filter should be specified.'),
-                And(lambda x: not ('exclude' in x and 'include' in x), error='"exclude" and "include" filters should '
-                                                                             'be specified as different list items.'),
-            )]
-        },
-        'instance': {
-            'region': And(str, len),
-            Optional('availabilityZone', default=''): str,
-            Optional('subnetId', default=''): str,
-            'instanceType': str,
-            Optional('onDemandInstance', default=False): bool,
-            Optional('amiName', default='SpottyAMI'): str,
-            Optional('keyName', default=''): str,
-            Optional('rootVolumeSize', default=0): And(Or(int, str), Use(str),
-                                                       Regex(r'^\d+$', error='Incorrect value for "rootVolumeSize".'),
-                                                       Use(int),
-                                                       And(lambda x: x > 0,
-                                                           error='"rootVolumeSize" should be greater than 0 or should '
-                                                                 ' not be specified.'),
-                                                       ),
-            Optional('maxPrice', default=0): And(Or(float, int, str), Use(str),
-                                                 Regex(r'^\d+(\.\d{1,6})?$', error='Incorrect value for "maxPrice".'),
-                                                 Use(float),
-                                                 And(lambda x: x > 0, error='"maxPrice" should be greater than 0 or '
-                                                                            'should  not be specified.'),
-                                                 ),
-            Optional('volumes', default=[]): And(
-                [{
-                    Optional('name', default=''): And(str, Regex(r'^[\w-]{1,255}$')),
-                    'directory': And(str, lambda x: x.startswith('/'), Use(lambda x: x.rstrip('/'))),
-                    Optional('size', default=0): And(int, lambda x: x > 0),
-                    Optional('deletionPolicy',
-                             default='create_snapshot'): And(str, lambda x: x in ['create_snapshot', 'update_snapshot',
-                                                                                  'retain', 'delete'],
-                                                             error='Incorrect value for "deletionPolicy".'
-                                                             ),
-                }],
-                And(lambda x: len(x) < 12, error='Maximum 11 volumes are supported at the moment.'),
-            ),
-            'docker': And(
-                {
-                    Optional('image', default=''): str,
-                    Optional('file', default=''): And(str,  # TODO: a proper regex that the filename is valid
-                                                      Regex(r'^[\w\.\/@-]*$',
-                                                            error='Invalid name for a Dockerfile'),
-                                                      And(lambda x: not x.endswith('/'),
-                                                          error='Invalid name for a Dockerfile'),
-                                                      And(lambda x: not os.path.isabs(x),
-                                                          error='Path to the Dockerfile should be relative to the '
-                                                                'project\'s root directory.'),
-                                                      And(lambda x: os.path.isfile(os.path.join(project_dir, x)),
-                                                          error='Dockerfile not found.'),
-                                                      ),
-                    Optional('workingDir', default=''): And(str,
-                                                            And(os.path.isabs,
-                                                                error='Use an absolute path when specifying a '
-                                                                      'working directory'),
-                                                            ),
-                    Optional('dataRoot', default=''): And(str,
-                                                          And(os.path.isabs,
-                                                              error='Use an absolute path when specifying a Docker '
-                                                                    'data root directory'),
-                                                          Use(lambda x: x.rstrip('/')),
-                                                          ),
-                    Optional('commands', default=''): str,
-                    Optional('runtimeParameters', default=[]): [str],
-                },
-                And(lambda x: x['image'] or x['file'], error='Either "image" or "file" should be specified.'),
-                And(lambda x: not (x['image'] and x['file']), error='"image" and "file" cannot be specified together.'),
-            ),
-            Optional('ports', default=[]): [And(int, lambda x: 0 <= x <= 65535)],
-            Optional('localSshPort', default=None): And(int, lambda x: 0 <= x <= 65535),
-        },
-        Optional('scripts', default={}): {
-            And(str, Regex(r'^[\w-]+$')): And(str, len),
-        },
-    })
-
-    return validate_config(schema, data)
-
-
-def convert_old_config(config):
-    from spotty.providers.aws.config.instance_config import VOLUME_TYPE_EBS
-
-    new_config = {
-        'project': {
-            'name': config['project']['name'],
-            'syncFilters': config['project']['syncFilters'],
-        },
-        'container': {
-            'projectDir': config['project']['remoteDir'],
-            'image': config['instance']['docker']['image'],
-            'file': config['instance']['docker']['file'],
-            'volumeMounts': [{
-                'name': volume['name'],
-                'mountPath': volume['directory'],
-            } for volume in config['instance']['volumes']],
-            'workingDir': config['instance']['docker']['workingDir'],
-            'commands': config['instance']['docker']['commands'],
-            'ports': config['instance']['ports'],
-            'runtimeParameters': config['instance']['docker']['runtimeParameters'],
-        },
-        'instances': [{
-            'name': 'instance',
-            'provider': 'aws',
-            'parameters': {
-                'region': config['instance']['region'],
-                'availabilityZone': config['instance']['availabilityZone'],
-                'subnetId': config['instance']['subnetId'],
-                'instanceType': config['instance']['instanceType'],
-                'onDemandInstance': config['instance']['onDemandInstance'],
-                'amiName': config['instance']['amiName'],
-                'rootVolumeSize': config['instance']['rootVolumeSize'],
-                'dockerDataRoot': config['instance']['docker']['dataRoot'],
-                'maxPrice': config['instance']['maxPrice'],
-                'localSshPort': config['instance']['localSshPort'],
-                'volumes': [{
-                    'name': volume['name'],
-                    'type': VOLUME_TYPE_EBS,
-                    'parameters': {
-                        'volumeName': volume['name'],
-                        'mountDir': volume['directory'],
-                        'size': volume['size'],
-                        'deletionPolicy': volume['deletionPolicy'],
-                    },
-                } for volume in config['instance']['volumes']],
-            },
-        }],
-        'scripts': config['scripts'],
-    }
-
-    def clear_empty_values(c):
-        if isinstance(c, dict):
-            c = {key: clear_empty_values(c[key]) for key in c if c[key]}
-        elif isinstance(c, list):
-            c = [clear_empty_values(row) for row in c]
-
-        return c
-
-    new_config = clear_empty_values(new_config)
-
-    return new_config
-
-
 def is_unique_value(x: List[dict], key):
     """Returns "True" if all values of the key in the list of dictionaries are unique."""
     return len(x) == len(set([v[key] for v in x]))
@@ -317,6 +185,14 @@ def validate_config(schema: Schema, config):
     try:
         validated = schema.validate(config)
     except SchemaError as e:
-        raise ValueError(e.errors[-1] if e.errors[-1] else e.autos[-1])
+        raise ValueError('Validation error: ' + (e.errors[-1] if e.errors[-1] else e.autos[-1]))
 
     return validated
+
+
+class WrongKey(Hook):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs, handler=self.raise_error)
+
+    def raise_error(self, key, *args):
+        raise SchemaForbiddenKeyError(self._error)
diff --git a/spotty/configuration.py b/spotty/configuration.py
index 0d27898..899b946 100644
--- a/spotty/configuration.py
+++ b/spotty/configuration.py
@@ -10,9 +10,9 @@ def get_spotty_config_dir():
     return path
 
 
-def get_spotty_keys_dir():
+def get_spotty_keys_dir(provider_name: str):
     """"Spotty keys directory."""
-    path = os.path.join(get_spotty_config_dir(), 'keys')
+    path = os.path.join(get_spotty_config_dir(), 'keys', provider_name)
     if not os.path.isdir(path):
         os.makedirs(path, mode=0o755, exist_ok=True)
 
diff --git a/spotty/helpers/__init__.py b/spotty/deployment/abstract_cloud_instance/__init__.py
similarity index 100%
rename from spotty/helpers/__init__.py
rename to spotty/deployment/abstract_cloud_instance/__init__.py
diff --git a/spotty/deployment/abstract_cloud_instance/abstract_bucket_manager.py b/spotty/deployment/abstract_cloud_instance/abstract_bucket_manager.py
new file mode 100644
index 0000000..87b71c8
--- /dev/null
+++ b/spotty/deployment/abstract_cloud_instance/abstract_bucket_manager.py
@@ -0,0 +1,18 @@
+from abc import ABC
+from spotty.deployment.abstract_cloud_instance.resources.abstract_bucket import AbstractBucket
+
+
+class AbstractBucketManager(ABC):
+
+    def __init__(self, project_name: str):
+        self._project_name = project_name
+
+    @property
+    def project_name(self) -> str:
+        return self._project_name
+
+    def get_bucket(self) -> AbstractBucket:
+        raise NotImplementedError
+
+    def create_bucket(self) -> AbstractBucket:
+        raise NotImplementedError
diff --git a/spotty/deployment/abstract_cloud_instance/abstract_cloud_instance_manager.py b/spotty/deployment/abstract_cloud_instance/abstract_cloud_instance_manager.py
new file mode 100644
index 0000000..e71cfa3
--- /dev/null
+++ b/spotty/deployment/abstract_cloud_instance/abstract_cloud_instance_manager.py
@@ -0,0 +1,183 @@
+import logging
+from abc import ABC, abstractmethod
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.config.project_config import ProjectConfig
+from spotty.deployment.abstract_cloud_instance.abstract_data_transfer import AbstractDataTransfer
+from spotty.deployment.abstract_cloud_instance.abstract_instance_deployment import AbstractInstanceDeployment
+from spotty.deployment.abstract_cloud_instance.abstract_bucket_manager import AbstractBucketManager
+from spotty.deployment.abstract_cloud_instance.errors.bucket_not_found import BucketNotFoundError
+from spotty.errors.instance_not_running import InstanceNotRunningError
+from spotty.deployment.abstract_ssh_instance_manager import AbstractSshInstanceManager
+
+
+class AbstractCloudInstanceManager(AbstractSshInstanceManager, ABC):
+
+    def __init__(self, project_config: ProjectConfig, instance_config: dict):
+        super().__init__(project_config, instance_config)
+
+        self._bucket_manager = self._get_bucket_manager()
+        self._data_transfer = self._get_data_transfer()
+        self._instance_deployment = self._get_instance_deployment()
+
+    @abstractmethod
+    def _get_bucket_manager(self) -> AbstractBucketManager:
+        """Returns an bucket manager."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def _get_data_transfer(self) -> AbstractDataTransfer:
+        """Returns a data transfer object."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def _get_instance_deployment(self) -> AbstractInstanceDeployment:
+        """Returns an instance deployment manager."""
+        raise NotImplementedError
+
+    @property
+    def bucket_manager(self) -> AbstractBucketManager:
+        """Returns a bucket manager."""
+        return self._bucket_manager
+
+    @property
+    def data_transfer(self) -> AbstractDataTransfer:
+        """Returns a data transfer object."""
+        return self._data_transfer
+
+    @property
+    def instance_deployment(self) -> AbstractInstanceDeployment:
+        """Returns an instance deployment manager."""
+        return self._instance_deployment
+
+    def is_running(self) -> bool:
+        """Checks if the instance is running."""
+        instance = self.instance_deployment.get_instance()
+        return instance and instance.is_running
+
+    def start(self, output: AbstractOutputWriter, dry_run=False):
+        # make sure the Dockerfile exists
+        self._check_dockerfile_exists()
+
+        if not dry_run:
+            # check if the instance is already running
+            instance = self.instance_deployment.get_instance()
+            if instance:
+                if instance.is_running:
+                    print('Instance is already running. Are you sure you want to restart it?')
+                    res = input('Type "y" to confirm: ')
+                    if res != 'y':
+                        raise ValueError('The operation was cancelled.')
+
+                    # terminating the instance to make EBS volumes available (the stack will be deleted later)
+                    output.write('Terminating the instance... ', newline=False)
+                    instance.terminate()
+                    output.write('DONE')
+
+                elif instance.is_stopped:
+                    # TODO: restart the instance if it stopped
+                    pass
+
+        # create or get existing bucket for the project
+        bucket_name = None
+        try:
+            bucket_name = self.bucket_manager.get_bucket().name
+        except BucketNotFoundError:
+            if not dry_run:
+                bucket_name = self.bucket_manager.create_bucket().name
+                output.write('Bucket "%s" was created.' % bucket_name)
+
+        # deploy the instance
+        self.instance_deployment.deploy(
+            container_commands=self.container_commands,
+            bucket_name=bucket_name,
+            data_transfer=self.data_transfer,
+            output=output,
+            dry_run=dry_run,
+        )
+
+    def stop(self, only_shutdown: bool, output: AbstractOutputWriter):
+        if only_shutdown:
+            output.write('Shutting down the instance... ', newline=False)
+            self.instance_deployment.get_instance().stop()
+            output.write('DONE')
+        else:
+            # delete the stack and apply deletion policies
+            self.instance_deployment.delete(output)
+
+    def clean(self, output: AbstractOutputWriter):
+        pass
+
+    def sync(self, output: AbstractOutputWriter, dry_run=False):
+        # get the project bucket name
+        bucket_name = self.bucket_manager.get_bucket().name
+
+        # sync the project with the S3 bucket
+        output.write('Syncing the project with the bucket...')
+        self.data_transfer.upload_local_to_bucket(bucket_name, dry_run=dry_run)
+
+        if not dry_run:
+            # sync the S3 bucket with the instance
+            output.write('Syncing the bucket with the instance...')
+            remote_cmd = self.data_transfer.get_download_bucket_to_instance_command(
+                bucket_name=bucket_name,
+                use_sudo=(not self.instance_config.container_config.run_as_host_user),
+            )
+            logging.debug('Remote sync command: ' + remote_cmd)
+
+            # execute the command on the host OS
+            exit_code = self.exec(remote_cmd)
+            if exit_code != 0:
+                raise ValueError('Failed to download files from the bucket to the instance')
+
+    def download(self, download_filters: list, output: AbstractOutputWriter, dry_run=False):
+        # get the project bucket name
+        bucket_name = self.bucket_manager.get_bucket().name
+
+        # sync files from the instance to a temporary S3 directory
+        output.write('Uploading files from the instance to the bucket...')
+        remote_cmd = self.data_transfer.get_upload_instance_to_bucket_command(
+            bucket_name=bucket_name,
+            download_filters=download_filters,
+            use_sudo=(not self.instance_config.container_config.run_as_host_user),
+            dry_run=dry_run,
+        )
+        logging.debug('Remote sync command: ' + remote_cmd)
+
+        # execute the command on the host OS
+        exit_code = self.exec(remote_cmd)
+        if exit_code != 0:
+            raise ValueError('Failed to upload files from the instance to the bucket')
+
+        if not dry_run:
+            # sync the project with the S3 bucket
+            output.write('Downloading files from the bucket to local...')
+            self.data_transfer.download_bucket_to_local(bucket_name=bucket_name, download_filters=download_filters)
+
+    @property
+    def ssh_host(self):
+        """Returns an IP address that will be used for SSH connections."""
+        if self._instance_config.local_ssh_port:
+            return '127.0.0.1'
+
+        # get a public IP address of the running instance
+        instance = self.instance_deployment.get_instance()
+        if not instance or not instance.is_running:
+            raise InstanceNotRunningError(self.instance_config.name)
+
+        public_ip_address = instance.public_ip_address
+        if not public_ip_address:
+            raise ValueError('The running instance doesn\'t have a public IP address.\n'
+                             'Use the "localSshPort" parameter if you want to create a tunnel to the instance.')
+
+        return public_ip_address
+
+    @property
+    def ssh_port(self) -> int:
+        if self._instance_config.local_ssh_port:
+            return self._instance_config.local_ssh_port
+
+        return 22
+
+    @property
+    def use_tmux(self) -> bool:
+        return True
diff --git a/spotty/deployment/abstract_cloud_instance/abstract_data_transfer.py b/spotty/deployment/abstract_cloud_instance/abstract_data_transfer.py
new file mode 100644
index 0000000..7ec33bb
--- /dev/null
+++ b/spotty/deployment/abstract_cloud_instance/abstract_data_transfer.py
@@ -0,0 +1,48 @@
+from abc import ABC, abstractmethod
+
+
+class AbstractDataTransfer(ABC):
+
+    def __init__(self, local_project_dir: str, host_project_dir: str, sync_filters: list, instance_name: str):
+        self._instance_name = instance_name
+        self._local_project_dir = local_project_dir
+        self._host_project_dir = host_project_dir
+        self._sync_filters = sync_filters
+
+    @property
+    def instance_name(self):
+        return self._instance_name
+
+    @property
+    @abstractmethod
+    def scheme_name(self) -> str:
+        raise NotImplementedError
+
+    def _get_bucket_project_path(self, bucket_name: str) -> str:
+        """A bucket path where the project files are located."""
+        return '%s://%s/project' % (self.scheme_name, bucket_name)
+
+    def _get_bucket_downloads_path(self, bucket_name: str) -> str:
+        """A bucket path where the downloaded files are located."""
+        return '%s://%s/download/instance-%s' % (self.scheme_name, bucket_name, self.instance_name)
+
+    @abstractmethod
+    def upload_local_to_bucket(self, bucket_name: str, dry_run: bool = False):
+        """Uploads files from local to the bucket."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def download_bucket_to_local(self, bucket_name: str, download_filters: list):
+        """Downloads files from the bucket to local."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def get_download_bucket_to_instance_command(self, bucket_name: str, use_sudo: bool = False) -> str:
+        """A remote command to download files from the bucket to the instance."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def get_upload_instance_to_bucket_command(self, bucket_name: str, download_filters: list, use_sudo: bool = False,
+                                              dry_run: bool = False) -> str:
+        """A remote command to upload files from the instance to the bucket."""
+        raise NotImplementedError
diff --git a/spotty/deployment/abstract_cloud_instance/abstract_instance_deployment.py b/spotty/deployment/abstract_cloud_instance/abstract_instance_deployment.py
new file mode 100644
index 0000000..51b20d5
--- /dev/null
+++ b/spotty/deployment/abstract_cloud_instance/abstract_instance_deployment.py
@@ -0,0 +1,32 @@
+from abc import abstractmethod, ABC
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.config.abstract_instance_config import AbstractInstanceConfig
+from spotty.deployment.abstract_cloud_instance.abstract_data_transfer import AbstractDataTransfer
+from spotty.deployment.abstract_cloud_instance.resources.abstract_instance import AbstractInstance
+from spotty.deployment.container.abstract_container_commands import AbstractContainerCommands
+
+
+class AbstractInstanceDeployment(ABC):
+
+    def __init__(self, instance_config: AbstractInstanceConfig):
+        self._instance_config = instance_config
+
+    @property
+    def instance_config(self) -> AbstractInstanceConfig:
+        return self._instance_config
+
+    @abstractmethod
+    def get_instance(self) -> AbstractInstance:
+        """Returns information about the instance it it exists."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def deploy(self, container_commands: AbstractContainerCommands, bucket_name: str,
+               data_transfer: AbstractDataTransfer, output: AbstractOutputWriter, dry_run: bool = False):
+        """Deploys or redeploys the instance."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def delete(self, output: AbstractOutputWriter):
+        """Deletes the stack with the instance and applies deletion policies for the volumes."""
+        raise NotImplementedError
diff --git a/spotty/providers/aws/aws_resources/__init__.py b/spotty/deployment/abstract_cloud_instance/errors/__init__.py
similarity index 100%
rename from spotty/providers/aws/aws_resources/__init__.py
rename to spotty/deployment/abstract_cloud_instance/errors/__init__.py
diff --git a/spotty/deployment/abstract_cloud_instance/errors/bucket_not_found.py b/spotty/deployment/abstract_cloud_instance/errors/bucket_not_found.py
new file mode 100644
index 0000000..d8d05e7
--- /dev/null
+++ b/spotty/deployment/abstract_cloud_instance/errors/bucket_not_found.py
@@ -0,0 +1,3 @@
+class BucketNotFoundError(Exception):
+    def __init__(self):
+        super().__init__('Bucket for the project not found.')
diff --git a/spotty/deployment/abstract_cloud_instance/file_structure.py b/spotty/deployment/abstract_cloud_instance/file_structure.py
new file mode 100644
index 0000000..7f73381
--- /dev/null
+++ b/spotty/deployment/abstract_cloud_instance/file_structure.py
@@ -0,0 +1,18 @@
+"""
+INSTANCE FILE STRUCTURE
+"""
+
+# a base temporary directory on an instance
+INSTANCE_SPOTTY_TMP_DIR = '/tmp/spotty'
+
+# a base directory for instance-related files and directories
+INSTANCE_DIR = INSTANCE_SPOTTY_TMP_DIR + '/instance'
+
+# helper scripts
+INSTANCE_SCRIPTS_DIR = INSTANCE_DIR + '/scripts'
+
+# instance startup scripts
+INSTANCE_STARTUP_SCRIPTS_DIR = INSTANCE_SCRIPTS_DIR + '/startup'
+
+# a path to the script that attaches user to the container
+CONTAINER_BASH_SCRIPT_PATH = INSTANCE_SCRIPTS_DIR + '/container_bash.sh'
diff --git a/spotty/providers/aws/deployment/__init__.py b/spotty/deployment/abstract_cloud_instance/resources/__init__.py
similarity index 100%
rename from spotty/providers/aws/deployment/__init__.py
rename to spotty/deployment/abstract_cloud_instance/resources/__init__.py
diff --git a/spotty/deployment/abstract_cloud_instance/resources/abstract_bucket.py b/spotty/deployment/abstract_cloud_instance/resources/abstract_bucket.py
new file mode 100644
index 0000000..29c0e11
--- /dev/null
+++ b/spotty/deployment/abstract_cloud_instance/resources/abstract_bucket.py
@@ -0,0 +1,8 @@
+from abc import ABC
+
+
+class AbstractBucket(ABC):
+
+    @property
+    def name(self):
+        raise NotImplementedError
diff --git a/spotty/deployment/abstract_cloud_instance/resources/abstract_instance.py b/spotty/deployment/abstract_cloud_instance/resources/abstract_instance.py
new file mode 100644
index 0000000..a51f2c1
--- /dev/null
+++ b/spotty/deployment/abstract_cloud_instance/resources/abstract_instance.py
@@ -0,0 +1,24 @@
+from abc import ABC
+
+
+class AbstractInstance(ABC):
+
+    @property
+    def public_ip_address(self):
+        raise NotImplementedError
+
+    @property
+    def is_running(self):
+        """Returns true if the instance is running."""
+        raise NotImplementedError
+
+    @property
+    def is_stopped(self):
+        """Returns true if the instance is stopped, so it can be restarted."""
+        raise NotImplementedError
+
+    def terminate(self, wait: bool = True):
+        raise NotImplementedError
+
+    def stop(self, wait: bool = True):
+        raise NotImplementedError
diff --git a/spotty/deployment/abstract_docker_instance_manager.py b/spotty/deployment/abstract_docker_instance_manager.py
new file mode 100644
index 0000000..646cd1f
--- /dev/null
+++ b/spotty/deployment/abstract_docker_instance_manager.py
@@ -0,0 +1,74 @@
+import os
+from abc import ABC
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.deployment.utils.commands import get_script_command
+from spotty.deployment.container.docker.docker_commands import DockerCommands
+from spotty.deployment.container.docker.scripts.start_container_script import StartContainerScript
+from spotty.deployment.container.docker.scripts.stop_container_script import StopContainerScript
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
+from spotty.errors.nothing_to_do import NothingToDoError
+from spotty.utils import render_table
+
+
+class AbstractDockerInstanceManager(AbstractInstanceManager, ABC):
+
+    @property
+    def container_commands(self) -> DockerCommands:
+        """A collection of commands to manage a container from the host OS."""
+        return DockerCommands(self.instance_config)
+
+    def is_container_running(self) -> bool:
+        """Checks if the container is running."""
+        is_running_cmd = self.container_commands.is_created(is_running=True)
+        exit_code = self.exec(is_running_cmd)
+
+        return exit_code == 0
+
+    def start_container(self, output: AbstractOutputWriter, dry_run=False):
+        """Starts or restarts container on the host OS."""
+        # make sure the Dockerfile exists
+        self._check_dockerfile_exists()
+
+        # sync the project with the instance
+        try:
+            self.sync(output, dry_run=dry_run)
+        except NothingToDoError:
+            pass
+
+        # generate a script that starts container
+        start_container_script = StartContainerScript(self.container_commands).render()
+        start_container_command = get_script_command('start-container', start_container_script)
+
+        # start the container
+        exit_code = self.exec(start_container_command)
+        if exit_code != 0:
+            raise ValueError('Failed to start the container')
+
+    def start(self, output: AbstractOutputWriter, dry_run=False):
+        # start or restart container
+        self.start_container(output, dry_run=dry_run)
+
+    def stop(self, only_shutdown: bool, output: AbstractOutputWriter):
+        # stop container
+        stop_container_script = StopContainerScript(self.container_commands).render()
+        stop_container_command = get_script_command('stop-container', stop_container_script)
+
+        exit_code = self.exec(stop_container_command)
+        if exit_code != 0:
+            raise ValueError('Failed to stop the container')
+
+    def get_status_text(self):
+        if self.is_container_running():
+            msg = 'Container is running.'
+        else:
+            msg = 'Container is not running.'
+
+        return render_table([(msg,)])
+
+    def _check_dockerfile_exists(self):
+        """Raises an error if a Dockerfile specified in the configuration file but doesn't exist."""
+        if self.instance_config.container_config.file:
+            dockerfile_path = os.path.join(self.project_config.project_dir, self.instance_config.container_config.file)
+            if not os.path.isfile(dockerfile_path):
+                raise ValueError('A Dockerfile specified in the container configuration but doesn\'t exist: ' +
+                                 dockerfile_path)
diff --git a/spotty/providers/abstract_instance_manager.py b/spotty/deployment/abstract_instance_manager.py
similarity index 66%
rename from spotty/providers/abstract_instance_manager.py
rename to spotty/deployment/abstract_instance_manager.py
index b3ac369..1b108d8 100644
--- a/spotty/providers/abstract_instance_manager.py
+++ b/spotty/deployment/abstract_instance_manager.py
@@ -1,7 +1,9 @@
+import subprocess
 from abc import ABC, abstractmethod
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
 from spotty.config.abstract_instance_config import AbstractInstanceConfig
 from spotty.config.project_config import ProjectConfig
+from spotty.deployment.container.abstract_container_commands import AbstractContainerCommands
 
 
 class AbstractInstanceManager(ABC):
@@ -10,13 +12,27 @@ def __init__(self, project_config: ProjectConfig, instance_config: dict):
         self._project_config = project_config
         self._instance_config = self._get_instance_config(instance_config)
 
+    @property
+    def project_config(self) -> ProjectConfig:
+        return self._project_config
+
+    @property
+    def instance_config(self) -> AbstractInstanceConfig:
+        return self._instance_config
+
     @abstractmethod
-    def _get_instance_config(self, config: dict) -> AbstractInstanceConfig:
+    def _get_instance_config(self, instance_config: dict) -> AbstractInstanceConfig:
         """A factory method to create a provider's instance config."""
         raise NotImplementedError
 
+    @property
+    @abstractmethod
+    def container_commands(self) -> AbstractContainerCommands:
+        """A collection of commands to manage a container from the host OS."""
+        raise NotImplementedError
+
     @abstractmethod
-    def is_running(self):
+    def is_running(self) -> bool:
         """Checks if the instance is running."""
         raise NotImplementedError
 
@@ -26,10 +42,19 @@ def start(self, output: AbstractOutputWriter, dry_run=False):
         raise NotImplementedError
 
     @abstractmethod
-    def stop(self, output: AbstractOutputWriter):
+    def start_container(self, output: AbstractOutputWriter, dry_run=False):
+        """Starts or restarts container on the host OS."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def stop(self, only_shutdown: bool, output: AbstractOutputWriter):
         """Deletes the stack."""
         raise NotImplementedError
 
+    def exec(self, command: str, tty: bool = True) -> int:
+        """Executes a command on the host OS."""
+        return subprocess.call(command, shell=True)
+
     @abstractmethod
     def clean(self, output: AbstractOutputWriter):
         """Deletes the stack."""
@@ -53,44 +78,7 @@ def get_status_text(self) -> str:
         """
         raise NotImplementedError
 
-    @abstractmethod
-    def get_public_ip_address(self) -> str:
-        """Returns a public IP address of the running instance."""
-        raise NotImplementedError
-
-    def get_ip_address(self):
-        """Returns an IP address that will be used for SSH connections."""
-        if self._instance_config.local_ssh_port:
-            return '127.0.0.1'
-
-        public_ip_address = self.get_public_ip_address()
-        if not public_ip_address:
-            raise ValueError('The running instance doesn\'t have a public IP address.\n'
-                             'Use the "localSshPort" parameter if you want to create a tunnel to the instance.')
-
-        return public_ip_address
-
-    @property
-    def ssh_port(self) -> int:
-        if self._instance_config.local_ssh_port:
-            return self._instance_config.local_ssh_port
-
-        return 22
-
     @property
-    @abstractmethod
-    def ssh_user(self) -> str:
-        raise NotImplementedError
-
-    @property
-    @abstractmethod
-    def ssh_key_path(self) -> str:
-        raise NotImplementedError
-
-    @property
-    def project_config(self) -> ProjectConfig:
-        return self._project_config
-
-    @property
-    def instance_config(self) -> AbstractInstanceConfig:
-        return self._instance_config
+    def use_tmux(self) -> bool:
+        """Use tmux when running a custom script or connecting to the instance."""
+        return False
diff --git a/spotty/deployment/abstract_ssh_instance_manager.py b/spotty/deployment/abstract_ssh_instance_manager.py
new file mode 100644
index 0000000..c1e2926
--- /dev/null
+++ b/spotty/deployment/abstract_ssh_instance_manager.py
@@ -0,0 +1,51 @@
+import logging
+import os
+from abc import abstractmethod
+from spotty.deployment.utils.commands import get_ssh_command
+from spotty.deployment.abstract_docker_instance_manager import AbstractDockerInstanceManager
+
+
+class AbstractSshInstanceManager(AbstractDockerInstanceManager):
+
+    def exec(self, command: str, tty: bool = True) -> int:
+        """Executes a command on the host OS."""
+        if not os.path.isfile(self.ssh_key_path):
+            raise ValueError('SSH key doesn\'t exist: ' + self.ssh_key_path)
+
+        ssh_command = get_ssh_command(self.ssh_host, self.ssh_port, self.ssh_user, self.ssh_key_path,
+                                      command, env_vars=self.ssh_env_vars, tty=tty)
+        logging.debug('SSH command: ' + ssh_command)
+
+        return super().exec(ssh_command)
+
+    @property
+    @abstractmethod
+    def ssh_host(self):
+        raise NotImplementedError
+
+    @property
+    @abstractmethod
+    def ssh_port(self) -> int:
+        raise NotImplementedError
+
+    @property
+    @abstractmethod
+    def ssh_key_path(self) -> str:
+        raise NotImplementedError
+
+    @property
+    def ssh_user(self) -> str:
+        return self.instance_config.user
+
+    @property
+    def ssh_env_vars(self) -> dict:
+        """Environmental variables that will be set when ssh to the instance."""
+        return {
+            'SPOTTY_CONTAINER_NAME': self.instance_config.full_container_name,
+            'SPOTTY_CONTAINER_WORKING_DIR': self.instance_config.container_config.working_dir,
+        }
+
+    @property
+    def use_tmux(self) -> bool:
+        """Use tmux when running a custom script or connecting to the instance."""
+        return True
diff --git a/spotty/providers/aws/deployment/cf_templates/__init__.py b/spotty/deployment/container/__init__.py
similarity index 100%
rename from spotty/providers/aws/deployment/cf_templates/__init__.py
rename to spotty/deployment/container/__init__.py
diff --git a/spotty/deployment/container/abstract_container_commands.py b/spotty/deployment/container/abstract_container_commands.py
new file mode 100644
index 0000000..c05f045
--- /dev/null
+++ b/spotty/deployment/container/abstract_container_commands.py
@@ -0,0 +1,17 @@
+from abc import ABC, abstractmethod
+from spotty.config.abstract_instance_config import AbstractInstanceConfig
+
+
+class AbstractContainerCommands(ABC):
+
+    def __init__(self, instance_config: AbstractInstanceConfig):
+        self._instance_config = instance_config
+
+    @property
+    def instance_config(self) -> AbstractInstanceConfig:
+        return self._instance_config
+
+    @abstractmethod
+    def exec(self, command: str, interactive: bool = False, tty: bool = False, user: str = None,
+             container_name: str = None, working_dir: str = None) -> str:
+        raise NotImplementedError
diff --git a/spotty/deployment/container/abstract_container_script.py b/spotty/deployment/container/abstract_container_script.py
new file mode 100644
index 0000000..5c56afd
--- /dev/null
+++ b/spotty/deployment/container/abstract_container_script.py
@@ -0,0 +1,16 @@
+from abc import ABC, abstractmethod
+from spotty.deployment.container.abstract_container_commands import AbstractContainerCommands
+
+
+class AbstractContainerScript(ABC):
+
+    def __init__(self, container_commands: AbstractContainerCommands):
+        self._commands = container_commands
+
+    @property
+    def commands(self) -> AbstractContainerCommands:
+        return self._commands
+
+    @abstractmethod
+    def render(self) -> str:
+        raise NotImplementedError
diff --git a/spotty/providers/aws/deployment/project_resources/__init__.py b/spotty/deployment/container/docker/__init__.py
similarity index 100%
rename from spotty/providers/aws/deployment/project_resources/__init__.py
rename to spotty/deployment/container/docker/__init__.py
diff --git a/spotty/deployment/container/docker/docker_commands.py b/spotty/deployment/container/docker/docker_commands.py
new file mode 100644
index 0000000..cef8d80
--- /dev/null
+++ b/spotty/deployment/container/docker/docker_commands.py
@@ -0,0 +1,94 @@
+import shlex
+from spotty.deployment.container.abstract_container_commands import AbstractContainerCommands
+from spotty.deployment.utils.cli import shlex_join
+
+
+class DockerCommands(AbstractContainerCommands):
+
+    def build(self, image_name: str) -> str:
+        if not self._instance_config.dockerfile_path:
+            raise ValueError('Cannot generate the "build" command as Dockerfile path is not specified')
+
+        if not self._instance_config.docker_context_path:
+            raise ValueError('Cannot generate the "build" command as Docker context path is not set')
+
+        build_cmd = 'docker build -t %s -f %s %s' % (image_name, shlex.quote(self._instance_config.dockerfile_path),
+                                                     shlex.quote(self._instance_config.docker_context_path))
+
+        if self._instance_config.container_config.run_as_host_user:
+            build_cmd += ' --build-arg USER_ID=$(id -u %s) --build-arg GROUP_ID=$(id -g %s)' \
+                         % (self._instance_config.user, self._instance_config.user)
+
+        return build_cmd
+
+    def run(self, image_name: str = None) -> str:
+        image_name = image_name if image_name else self._instance_config.container_config.image
+
+        # prepare "docker run" arguments
+        args = ['-td'] + self._instance_config.container_config.runtime_parameters
+
+        if self._instance_config.container_config.host_network:
+            args += ['--net=host']
+
+        for port in self._instance_config.container_config.ports:
+            host_port = port['hostPort']
+            container_port = port['containerPort']
+            args += ['-p', ('%d:%d' % (host_port, container_port)) if host_port else str(container_port)]
+
+        for volume_mount in self._instance_config.volume_mounts:
+            args += ['-v', '%s:%s:%s' % (volume_mount.host_path, volume_mount.mount_path, volume_mount.mode)]
+
+        for env_name, env_value in self._instance_config.container_config.env.items():
+            args += ['-e', '%s=%s' % (env_name, env_value)]
+
+        args += ['--name', self._instance_config.full_container_name]
+
+        run_cmd = 'docker run $(nvidia-smi &> /dev/null && echo "--gpus all")'
+
+        if self._instance_config.container_config.run_as_host_user:
+            run_cmd += ' -u $(id -u %s):$(id -g %s) -e HOST_USER_ID=$(id -u %s) -e HOST_GROUP_ID=$(id -g %s)' \
+                       % tuple([self._instance_config.user] * 4)
+
+        run_cmd += ' %s %s /bin/sh > /dev/null' % (shlex_join(args), image_name)
+
+        return run_cmd
+
+    def is_created(self, container_name: str = None, is_running: bool = False) -> str:
+        container_name = container_name if container_name else self._instance_config.full_container_name
+        show_all = '' if is_running else 'a'
+
+        test_cmd = '[ $(docker ps -q%s --filter name="%s" | wc -c) -ne 0 ]' % (show_all, container_name)
+
+        return test_cmd
+
+    def remove(self):
+        return 'docker rm -f "%s" > /dev/null' % self._instance_config.full_container_name
+
+    def exec(self, command: str, interactive: bool = False, tty: bool = False, user: str = None,
+             container_name: str = None, working_dir: str = None) -> str:
+        container_name = container_name if container_name else self._instance_config.full_container_name
+        working_dir = working_dir if working_dir else self._instance_config.container_config.working_dir
+
+        exec_cmd = 'docker exec'
+
+        if interactive:
+            exec_cmd += ' -i'
+
+        if tty:
+            exec_cmd += ' -t'
+
+        if user:
+            exec_cmd += ' -u ' + shlex.quote(user)
+
+        if working_dir:
+            # no quoting, it can be environmental variable
+            exec_cmd += ' -w ' + working_dir
+
+        exec_cmd += ' %s %s' % (container_name, command)
+
+        # run "exec" command only if the container is running
+        test_cmd = self.is_created(container_name, is_running=True)
+        error_msg = 'Container is not running.\\nUse the "spotty start -C" command to start it.\\n'
+        cond_exec_cmd = 'if %s; then %s; else printf %s; exit 1; fi' % (test_cmd, exec_cmd, shlex.quote(error_msg))
+
+        return cond_exec_cmd
diff --git a/spotty/providers/gcp/commands/__init__.py b/spotty/deployment/container/docker/scripts/__init__.py
similarity index 100%
rename from spotty/providers/gcp/commands/__init__.py
rename to spotty/deployment/container/docker/scripts/__init__.py
diff --git a/spotty/deployment/container/docker/scripts/abstract_docker_script.py b/spotty/deployment/container/docker/scripts/abstract_docker_script.py
new file mode 100644
index 0000000..0078715
--- /dev/null
+++ b/spotty/deployment/container/docker/scripts/abstract_docker_script.py
@@ -0,0 +1,10 @@
+from abc import ABC
+from spotty.deployment.container.docker.docker_commands import DockerCommands
+from spotty.deployment.container.abstract_container_script import AbstractContainerScript
+
+
+class AbstractDockerScript(AbstractContainerScript, ABC):
+
+    @property
+    def commands(self) -> DockerCommands:
+        return self._commands
diff --git a/spotty/deployment/container/docker/scripts/container_bash_script.py b/spotty/deployment/container/docker/scripts/container_bash_script.py
new file mode 100644
index 0000000..ff51595
--- /dev/null
+++ b/spotty/deployment/container/docker/scripts/container_bash_script.py
@@ -0,0 +1,22 @@
+import os
+import chevron
+from spotty.deployment.utils.commands import get_bash_command
+from spotty.deployment.container.docker.scripts.abstract_docker_script import AbstractDockerScript
+
+
+class ContainerBashScript(AbstractDockerScript):
+
+    def render(self) -> str:
+        # read template file
+        template_path = os.path.join(os.path.dirname(__file__), 'data', 'container_bash.sh.tpl')
+        with open(template_path) as f:
+            template = f.read()
+
+        # render the script
+        content = chevron.render(template, data={
+            'docker_exec_bash': self.commands.exec(get_bash_command(), interactive=True, tty=True,
+                                                   container_name='$SPOTTY_CONTAINER_NAME',
+                                                   working_dir='$SPOTTY_CONTAINER_WORKING_DIR'),
+        })
+
+        return content
diff --git a/spotty/deployment/container/docker/scripts/data/container_bash.sh.tpl b/spotty/deployment/container/docker/scripts/data/container_bash.sh.tpl
new file mode 100644
index 0000000..5db399e
--- /dev/null
+++ b/spotty/deployment/container/docker/scripts/data/container_bash.sh.tpl
@@ -0,0 +1,10 @@
+#!/bin/bash -e
+
+if [ -z "$SPOTTY_CONTAINER_NAME" ]; then
+  echo -e "\nSPOTTY_CONTAINER_NAME environmental variable is not set.\n"
+  exit 1
+fi
+
+SPOTTY_CONTAINER_WORKING_DIR=${SPOTTY_CONTAINER_WORKING_DIR:-/}
+
+{{{docker_exec_bash}}}
diff --git a/spotty/deployment/container/docker/scripts/data/start_container.sh.tpl b/spotty/deployment/container/docker/scripts/data/start_container.sh.tpl
new file mode 100644
index 0000000..4d88586
--- /dev/null
+++ b/spotty/deployment/container/docker/scripts/data/start_container.sh.tpl
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+
+{{bash_flags}}
+
+if {{{is_created_cmd}}}; then
+  printf 'Removing existing container... '
+  {{{remove_cmd}}}
+  echo 'DONE'
+fi
+
+{{> before_image_build}}
+
+{{#build_image_cmd}}
+echo 'Building Docker image...'
+{{{build_image_cmd}}}
+{{/build_image_cmd}}
+
+{{> before_container_run}}
+
+i=0
+until [ "$i" -ge 3 ]
+do
+  if [ "$i" -eq 0 ]; then
+    printf 'Starting container... '
+  else
+    echo "Retrying to start the container $i..."
+  fi
+
+  RUN_EXIT_CODE=0
+  {{{start_container_cmd}}} || RUN_EXIT_CODE=$?
+
+  if [ "$RUN_EXIT_CODE" -ne 125 ]; then
+    break
+  fi
+
+  i=$((i+1))
+  sleep 10
+done
+
+if [ "$RUN_EXIT_CODE" -ne 0 ]; then
+  exit $RUN_EXIT_CODE
+fi
+
+echo 'DONE'
+
+{{> before_startup_commands}}
+
+{{#docker_exec_startup_script_cmd}}
+echo 'Running startup commands...'
+{{{docker_exec_startup_script_cmd}}}
+{{/docker_exec_startup_script_cmd}}
diff --git a/spotty/deployment/container/docker/scripts/data/stop_container.sh.tpl b/spotty/deployment/container/docker/scripts/data/stop_container.sh.tpl
new file mode 100644
index 0000000..4524bca
--- /dev/null
+++ b/spotty/deployment/container/docker/scripts/data/stop_container.sh.tpl
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+
+set -e
+
+if {{{is_created_cmd}}}; then
+  printf 'Removing the container... '
+  {{{remove_cmd}}}
+  echo 'DONE'
+else
+  echo 'Container is not running.'
+fi
diff --git a/spotty/deployment/container/docker/scripts/start_container_script.py b/spotty/deployment/container/docker/scripts/start_container_script.py
new file mode 100644
index 0000000..117065a
--- /dev/null
+++ b/spotty/deployment/container/docker/scripts/start_container_script.py
@@ -0,0 +1,51 @@
+import os
+import time
+import chevron
+from spotty.deployment.utils.commands import get_script_command
+from spotty.deployment.container.docker.scripts.abstract_docker_script import AbstractDockerScript
+
+
+class StartContainerScript(AbstractDockerScript):
+
+    def _partials(self) -> dict:
+        return {
+            'before_image_build': '',
+            'before_container_run': '',
+            'before_startup_commands': '',
+        }
+
+    def render(self, print_trace: bool = False) -> str:
+        # read template file
+        template_path = os.path.join(os.path.dirname(__file__), 'data', 'start_container.sh.tpl')
+        with open(template_path) as f:
+            template = f.read()
+
+        # generate "docker build" command if necessary
+        if self._commands.instance_config.dockerfile_path:
+            image_name = '%s:%d' % (self._commands.instance_config.full_container_name, time.time())
+            build_image_cmd = self._commands.build(image_name)
+        else:
+            image_name = self._commands.instance_config.container_config.image
+            build_image_cmd = ''
+
+        # generate a command to run the startup script
+        exec_script_cmd = ''
+        if self._commands.instance_config.container_config.commands:
+            startup_script_cmd = get_script_command('container-startup-commands',
+                                                    self._commands.instance_config.container_config.commands)
+            exec_script_cmd = self.commands.exec(startup_script_cmd, user='root')
+
+        # generate "docker run" command
+        run_container_cmd = self.commands.run(image_name)
+
+        # render the script
+        content = chevron.render(template, data={
+            'bash_flags': 'set -xe' if print_trace else 'set -e',
+            'is_created_cmd': self.commands.is_created(),
+            'remove_cmd': self.commands.remove(),
+            'build_image_cmd': build_image_cmd,
+            'start_container_cmd': run_container_cmd,
+            'docker_exec_startup_script_cmd': exec_script_cmd,
+        }, partials_dict=self._partials())
+
+        return content
diff --git a/spotty/deployment/container/docker/scripts/stop_container_script.py b/spotty/deployment/container/docker/scripts/stop_container_script.py
new file mode 100644
index 0000000..5d10552
--- /dev/null
+++ b/spotty/deployment/container/docker/scripts/stop_container_script.py
@@ -0,0 +1,20 @@
+import os
+import chevron
+from spotty.deployment.container.docker.scripts.abstract_docker_script import AbstractDockerScript
+
+
+class StopContainerScript(AbstractDockerScript):
+
+    def render(self) -> str:
+        # read template file
+        template_path = os.path.join(os.path.dirname(__file__), 'data', 'stop_container.sh.tpl')
+        with open(template_path) as f:
+            template = f.read()
+
+        # render the script
+        content = chevron.render(template, data={
+            'is_created_cmd': self.commands.is_created(),
+            'remove_cmd': self.commands.remove(),
+        })
+
+        return content
diff --git a/spotty/deployment/container_deployment.py b/spotty/deployment/container_deployment.py
deleted file mode 100644
index b54a8c5..0000000
--- a/spotty/deployment/container_deployment.py
+++ /dev/null
@@ -1,97 +0,0 @@
-from collections import namedtuple, OrderedDict
-import os
-from subprocess import list2cmdline
-from typing import List
-from spotty.config.container_config import ContainerConfig
-from spotty.config.validation import is_subdir
-from spotty.deployment.abstract_instance_volume import AbstractInstanceVolume
-
-
-VolumeMount = namedtuple('VolumeMount', ['name', 'host_dir', 'container_dir'])
-
-
-class ContainerDeployment(object):
-
-    def __init__(self, project_name: str, volumes: List[AbstractInstanceVolume], container_config: ContainerConfig):
-        self._project_name = project_name
-        self._config = container_config
-
-        # get container volumes and host project directory
-        self._volume_mounts, self._host_project_dir = self._get_volume_mounts(volumes)
-
-    @property
-    def config(self) -> ContainerConfig:
-        return self._config
-
-    @property
-    def dockerfile_path(self):
-        """Dockerfile path on the host OS."""
-        dockerfile_path = self.config.file
-        if dockerfile_path:
-            dockerfile_path = self.host_project_dir + '/' + dockerfile_path
-
-        return dockerfile_path
-
-    @property
-    def docker_context_path(self):
-        """Docker build's context path on the host OS."""
-        dockerfile_path = self.dockerfile_path
-        if not dockerfile_path:
-            return ''
-
-        return os.path.dirname(dockerfile_path)
-
-    @property
-    def host_project_dir(self):
-        return self._host_project_dir
-
-    @property
-    def volume_mounts(self) -> List[VolumeMount]:
-        return self._volume_mounts
-
-    def get_runtime_parameters(self, is_nvidia_runtime: bool):
-        """Returns parameters for the ""docker run" command."""
-        parameters = self._config.runtime_parameters + ['-td', '--net=host']
-        if is_nvidia_runtime:
-            parameters += ['--gpus', 'all']
-
-        for volume_mount in self.volume_mounts:
-            parameters += ['-v', '%s:%s' % (volume_mount.host_dir, volume_mount.container_dir)]
-
-        return list2cmdline(parameters)
-
-    def _get_volume_mounts(self, volumes: List[AbstractInstanceVolume]):
-        """Get container volume mounts."""
-        # get mount directories for the volumes
-        mount_dirs = OrderedDict([(volume.name, volume.mount_dir) for volume in volumes])
-
-        # get container volumes mapping
-        volume_mounts = []
-        for container_volume in self.config.volume_mounts:
-            volume_name = container_volume['name']
-            host_dir = mount_dirs.get(volume_name, '/tmp/spotty/container/volumes/%s' % volume_name)
-
-            volume_mounts.append(VolumeMount(
-                name=volume_name,
-                host_dir=host_dir,
-                container_dir=container_volume['mountPath'],
-            ))
-
-        # get host project directory
-        host_project_dir = None
-        for name, host_dir, container_dir in volume_mounts:
-            if is_subdir(self.config.project_dir, container_dir):
-                project_subdir = os.path.relpath(self.config.project_dir, container_dir)
-                host_project_dir = host_dir + '/' + project_subdir
-                break
-
-        if not host_project_dir:
-            # use temporary directory for the project
-            host_project_dir = '/tmp/spotty/container/volumes/.project'
-            volume_mounts.append(VolumeMount(
-                name=None,
-                host_dir=host_project_dir,
-                container_dir=self.config.project_dir,
-            ))
-
-        return volume_mounts, host_project_dir
diff --git a/spotty/providers/gcp/deployment/__init__.py b/spotty/deployment/utils/__init__.py
similarity index 100%
rename from spotty/providers/gcp/deployment/__init__.py
rename to spotty/deployment/utils/__init__.py
diff --git a/spotty/deployment/utils/cli.py b/spotty/deployment/utils/cli.py
new file mode 100644
index 0000000..ee41ed3
--- /dev/null
+++ b/spotty/deployment/utils/cli.py
@@ -0,0 +1,8 @@
+import shlex
+
+
+def shlex_join(split_command: list):
+    """Return a shell-escaped string from *split_command*.
+    Copy-pasted from the Python 3.8 code.
+    """
+    return ' '.join(shlex.quote(arg) for arg in split_command)
diff --git a/spotty/deployment/utils/commands.py b/spotty/deployment/utils/commands.py
new file mode 100644
index 0000000..0552aeb
--- /dev/null
+++ b/spotty/deployment/utils/commands.py
@@ -0,0 +1,96 @@
+import base64
+import os
+import shlex
+from spotty.deployment.utils.cli import shlex_join
+
+
+def get_bash_command() -> str:
+    return '/usr/bin/env bash'
+
+
+def get_script_command(script_name: str, script_content: str, script_args: list = None) -> str:
+    """Encodes a multi-line script into base64 and returns a one-line command
+    that unpacks the script to a temporary file and runs it."""
+
+    # encode the script content to base64
+    script_base64 = base64.b64encode(script_content.encode('utf-8')).decode('utf-8')
+
+    # command to decode the script, save it to a temporary file and run inside the container
+    script_args = shlex_join(script_args) if script_args else ''
+
+    script_cmd = ' && '.join([
+        'TMPDIR=${TMPDIR%/}',
+        'TMP_SCRIPT_PATH=$(mktemp ${TMPDIR:-/tmp}/spotty-%s.XXXXXXXX)' % script_name,
+        'chmod +x $TMP_SCRIPT_PATH',
+        'echo %s | base64 -d > $TMP_SCRIPT_PATH' % script_base64,
+        '$TMP_SCRIPT_PATH ' + script_args,
+    ])
+
+    script_cmd = '%s -c %s' % (get_bash_command(), shlex.quote(script_cmd))
+
+    return script_cmd
+
+
+def get_log_command(command: str, log_file_path: str) -> str:
+    # log the command outputs to a file on the host OS
+    log_dir = os.path.dirname(log_file_path)
+    log_cmd = '; '.join([
+        'set -o pipefail',
+        ' && '.join([
+            'mkdir -p' + log_dir,
+            '(%s) 2>&1 | tee %s' % (command, log_file_path),
+        ]),
+    ])
+
+    return log_cmd
+
+
+def get_tmux_session_command(command: str, session_name: str, window_name: str = None, default_command: str = None,
+                             keep_pane: bool = False) -> str:
+    session_cmd = 'tmux new -A -s ' + session_name
+    if window_name:
+        session_cmd += ' -n ' + window_name
+
+    if command:
+        # keep the pane alive when the script is finished
+        keep_pane_cmd = 'tmux set -w remain-on-exit on; ' if keep_pane else ''
+
+        # set the default command (to automatically run bash inside the container when a new window is created)
+        default_command_cmd = ('tmux set default-command %s; ' % shlex.quote(default_command)) \
+            if default_command else ''
+
+        # keep the pane alive if the script is failed
+        tmux_cmd = '%s%s(%s) || tmux set -w remain-on-exit on' % (keep_pane_cmd, default_command_cmd, command)
+
+        # run the command inside the tmux session
+        session_cmd += ' ' + shlex.quote(tmux_cmd)
+
+    # use tmux only if it's installed
+    session_cmd = 'if command -v tmux &> /dev/null; then %s; else %s; fi' % (session_cmd, command)
+
+    return session_cmd
+
+
+def get_ssh_command(host: str, port: int, user: str, key_path: str, command: str, env_vars: dict = None,
+                    tty: bool = True, quiet: bool = False) -> str:
+
+    ssh_command = 'ssh -i %s -o StrictHostKeyChecking=no -o ConnectTimeout=10' % shlex.quote(key_path)
+
+    if tty:
+        ssh_command += ' -t'
+
+    if port != 22:
+        ssh_command += ' -p %d' % port
+
+    if quiet:
+        ssh_command += ' -q'
+
+    # export environmental variables
+    if env_vars:
+        export_cmd = '; '.join(['export %s=%s' % (name, shlex.quote(val)) for name, val in env_vars.items()])
+        command = '%s; %s' % (export_cmd, command)
+
+    # final SSH command
+    ssh_command += ' %s@%s %s' % (user, host, shlex.quote(command))
+
+    return ssh_command
diff --git a/spotty/helpers/print_info.py b/spotty/deployment/utils/print_info.py
similarity index 61%
rename from spotty/helpers/print_info.py
rename to spotty/deployment/utils/print_info.py
index 4cb67af..423c9d1 100644
--- a/spotty/helpers/print_info.py
+++ b/spotty/deployment/utils/print_info.py
@@ -1,24 +1,22 @@
 from typing import List
-from spotty.deployment.abstract_instance_volume import AbstractInstanceVolume
-from spotty.deployment.container_deployment import VolumeMount
+from spotty.config.abstract_instance_config import VolumeMount
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.config.container_config import PROJECT_VOLUME_MOUNT_NAME
 from spotty.utils import render_table
 
 
 def render_volumes_info_table(volume_mounts: List[VolumeMount], volumes: List[AbstractInstanceVolume]):
-    table = [('Name', 'Container Dir', 'Type', 'Deletion Policy')]
+    table = [('Name', 'Mount Path', 'Type', 'Deletion Policy')]
 
     # add volume mounts to the info table
     volumes_dict = {volume.name: volume for volume in volumes}
     for volume_mount in volume_mounts:
-        if volume_mount.name in volumes_dict:
+        if not volume_mount.hidden:
             # the volume will be mounted to the container
             volume = volumes_dict[volume_mount.name]
+            vol_mount_name = '-' if volume_mount.name == PROJECT_VOLUME_MOUNT_NAME else volume_mount.name
             deletion_policy = volume.deletion_policy_title if volume.deletion_policy_title else '-'
-            table.append((volume_mount.name, volume_mount.container_dir, volume.title, deletion_policy))
-        else:
-            # a temporary directory will be mounted to the container
-            vol_mount_name = '-' if volume_mount.name is None else volume_mount.name
-            table.append((vol_mount_name, volume_mount.container_dir, 'temporary directory', '-'))
+            table.append((vol_mount_name, volume_mount.mount_path, volume.title, deletion_policy))
 
     # add volumes that were not mounted to the container to the info table
     volume_mounts_dict = {volume_mount.name for volume_mount in volume_mounts}
diff --git a/spotty/helpers/run.py b/spotty/deployment/utils/user_scripts.py
similarity index 76%
rename from spotty/helpers/run.py
rename to spotty/deployment/utils/user_scripts.py
index 5abd3f5..c8c18ae 100644
--- a/spotty/helpers/run.py
+++ b/spotty/deployment/utils/user_scripts.py
@@ -1,13 +1,13 @@
 import re
 import chevron
-from chevron.tokenizer import tokenize
+from spotty.deployment.utils.commands import get_bash_command
 
 
-def parse_parameters(script_params: str):
+def parse_script_parameters(script_params: str):
     """Parses script parameters."""
     params = {}
     for param in script_params:
-        match = re.match('(\w+)=(.*)', param)
+        match = re.match('(\\w+)=(.*)', param)
         if not match:
             raise ValueError('Invalid format for the script parameter: "%s" (the "PARAMETER=VALUE" format is expected).'
                              % param)
@@ -30,7 +30,7 @@ def render_script(template: str, params: dict):
     Raises an exception if one of the provided parameters doesn't
     exist in the template.
     """
-    tokens = list(tokenize(template))
+    tokens = list(chevron.tokenizer.tokenize(template))
     template_keys = set()
     for tag, key in tokens:
         if tag not in ['literal', 'no escape', 'variable', 'set delimiter']:
@@ -43,4 +43,9 @@ def render_script(template: str, params: dict):
         if key not in template_keys:
             raise ValueError('Parameter "%s" doesn\'t exist in the script.' % key)
 
-    return chevron.render(tokens, params)
+    content = chevron.render(tokens, params)
+
+    if content[:2] != '#!':
+        content = ('#!%s\n\nset -xe\n\n' % get_bash_command()) + content
+
+    return content
diff --git a/spotty/errors/nothing_to_do.py b/spotty/errors/nothing_to_do.py
new file mode 100644
index 0000000..56f8dc2
--- /dev/null
+++ b/spotty/errors/nothing_to_do.py
@@ -0,0 +1,2 @@
+class NothingToDoError(Exception):
+    pass
diff --git a/spotty/helpers/commands.py b/spotty/helpers/commands.py
deleted file mode 100644
index 79b18cd..0000000
--- a/spotty/helpers/commands.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from argparse import ArgumentParser
-import pkg_resources
-
-
-def add_subparsers(parser: ArgumentParser, command_classes: list):
-    """Adds commands to the parser."""
-    subparsers = parser.add_subparsers()
-    for command_class in command_classes:
-        command = command_class()
-        subparser = subparsers.add_parser(command.name, help=command.description)
-        subparser.set_defaults(command=command, parser=subparser)
-        command.configure(subparser)
-
-
-def get_custom_commands():
-    """Returns custom commands that integrated through entry points."""
-    return [entry_point.load() for entry_point in pkg_resources.iter_entry_points('spotty.commands')]
diff --git a/spotty/helpers/ssh.py b/spotty/helpers/ssh.py
deleted file mode 100644
index 4af0ead..0000000
--- a/spotty/helpers/ssh.py
+++ /dev/null
@@ -1,62 +0,0 @@
-import base64
-import subprocess
-
-
-def get_ssh_command(host: str, port: int, user: str, key_path: str, remote_cmd: str, quiet: bool = False) -> list:
-    ssh_command = ['ssh', '-ti', key_path, '-o', 'StrictHostKeyChecking no']
-
-    if port != 22:
-        ssh_command += ['-p', str(port)]
-
-    if quiet:
-        ssh_command += ['-q']
-
-    ssh_command += ['%s@%s' % (user, host), remote_cmd]
-
-    return ssh_command
-
-
-def run_script(host: str, port: int, user: str, key_path: str, script_name: str, script_content: str,
-               tmux_session_name: str, restart: bool = False, logging: bool = False):
-    # encode the script content to base64
-    script_base64 = base64.b64encode(script_content.encode('utf-8')).decode('utf-8')
-
-    # a remote path where the script will be uploaded
-    instance_script_path = '/tmp/spotty/container/scripts/run/%s.sh' % script_name
-    container_script_path = '/tmp/scripts/run/%s.sh' % script_name
-
-    # command to attach user to existing tmux session
-    attach_session_cmd = subprocess.list2cmdline(['tmux', 'attach', '-t', tmux_session_name, '>', '/dev/null',
-                                                  '2>&1'])
-
-    # command to kill session in case of a restart
-    kill_session_cmd = subprocess.list2cmdline(['tmux', 'kill-session', '-t', tmux_session_name, '>', '/dev/null',
-                                                '2>&1'])
-
-    # command to upload user script to the instance
-    upload_script_cmd = subprocess.list2cmdline(['echo', script_base64, '|', 'base64', '-d', '>', instance_script_path])
-
-    # log the script outputs to the file
-    log_cmd = ['2>&1', '|', 'tee', '/var/log/spotty/run/%s-`date +%%s`.log' % script_name] if logging else []
-
-    # command to run user script inside the docker container
-    docker_cmd = subprocess.list2cmdline(['sudo', '/tmp/spotty/instance/scripts/container_bash.sh', '-xe',
-                                          container_script_path] + log_cmd)
-
-    # command to create new tmux session and run user script
-    new_session_cmd = subprocess.list2cmdline(['tmux', 'new', '-s', tmux_session_name, '-n', script_name,
-                                               'tmux set remain-on-exit on && %s' % docker_cmd])
-
-    if restart:
-        # composition of the commands: killing the script session if it already exists, then uploading the script
-        # to the instance, creating new tmux session and running the script inside the Docker container
-        remote_cmd = '%s; (%s && %s)' % (kill_session_cmd, upload_script_cmd, new_session_cmd)
-    else:
-        # composition of the commands: trying to attach the user to the existing tmux session. If it doesn't exist,
-        # uploading the user script to the instance, creating new tmux session and running that script
-        # inside the Docker container
-        remote_cmd = '%s || (%s && %s)' % (attach_session_cmd, upload_script_cmd, new_session_cmd)
-
-    # connect to the instance and run the command
-    ssh_command = get_ssh_command(host, port, user, key_path, remote_cmd)
-    subprocess.call(ssh_command)
diff --git a/spotty/providers/aws/aws_resources/instance.py b/spotty/providers/aws/aws_resources/instance.py
deleted file mode 100644
index cdc8108..0000000
--- a/spotty/providers/aws/aws_resources/instance.py
+++ /dev/null
@@ -1,66 +0,0 @@
-from datetime import datetime
-from spotty.providers.aws.helpers.spot_prices import get_current_spot_price
-
-
-class Instance(object):
-
-    def __init__(self, ec2, instance_info):
-        self._ec2 = ec2
-        self._instance_info = instance_info
-
-    @staticmethod
-    def get_by_stack_name(ec2, stack_name):
-        """Returns the running instance by its stack name
-           or None if the instance is not running.
-        """
-        res = ec2.describe_instances(Filters=[
-            {'Name': 'tag:aws:cloudformation:stack-name', 'Values': [stack_name]},
-            {'Name': 'instance-state-name', 'Values': ['running']},
-        ])
-
-        if len(res['Reservations']) > 1:
-            raise ValueError('Several running instances for the stack "%s" are found.' % stack_name)
-
-        if not len(res['Reservations']):
-            return None
-
-        return Instance(ec2, res['Reservations'][0]['Instances'][0])
-
-    def terminate(self):
-        return self._ec2.terminate_instances(InstanceIds=[self.instance_id])
-
-    def wait_instance_terminated(self):
-        waiter = self._ec2.get_waiter('instance_terminated')
-        waiter.wait(InstanceIds=[self.instance_id])
-
-    @property
-    def instance_id(self):
-        return self._instance_info['InstanceId']
-
-    @property
-    def public_ip_address(self) -> str:
-        return self._instance_info.get('PublicIpAddress', None)
-
-    @property
-    def state(self) -> str:
-        return self._instance_info['State']['Name']
-
-    @property
-    def instance_type(self) -> str:
-        return self._instance_info['InstanceType']
-
-    @property
-    def availability_zone(self) -> str:
-        return self._instance_info['Placement']['AvailabilityZone']
-
-    @property
-    def launch_time(self) -> datetime:
-        return self._instance_info['LaunchTime']
-
-    @property
-    def lifecycle(self) -> str:
-        return self._instance_info.get('InstanceLifecycle')
-
-    def get_spot_price(self):
-        """Get current Spot Instance price for this instance."""
-        return get_current_spot_price(self._ec2, self.instance_type, self.availability_zone)
diff --git a/spotty/providers/aws/aws_resources/stack.py b/spotty/providers/aws/aws_resources/stack.py
deleted file mode 100644
index 141673e..0000000
--- a/spotty/providers/aws/aws_resources/stack.py
+++ /dev/null
@@ -1,108 +0,0 @@
-from time import sleep
-from botocore.exceptions import EndpointConnectionError, ClientError
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-
-
-class Stack(object):
-
-    def __init__(self, cf, stack_info):
-        self._cf = cf
-        self._stack_info = stack_info
-
-    @staticmethod
-    def get_by_name(cf, stack_name: str):
-        """Returns a Stack by its name."""
-        try:
-            res = cf.describe_stacks(StackName=stack_name)
-        except ClientError as e:
-            # ignore an exception if it raised because the stack doesn't exist
-            error_code = e.response.get('Error', {}).get('Code')
-            if error_code != 'ValidationError':
-                raise e
-
-            res = {'Stacks': []}
-
-        if not len(res['Stacks']):
-            return None
-
-        return Stack(cf, res['Stacks'][0])
-
-    @staticmethod
-    def create_stack(cf, *args, **kwargs):
-        res = cf.create_stack(*args, **kwargs)
-        return Stack(cf, res)
-
-    @staticmethod
-    def update_stack(cf, *args, **kwargs):
-        res = cf.update_stack(*args, **kwargs)
-        return Stack(cf, res)
-
-    @property
-    def stack_id(self) -> str:
-        return self._stack_info['StackId']
-
-    @property
-    def name(self) -> str:
-        return self._stack_info['StackName']
-
-    @property
-    def status(self) -> str:
-        return self._stack_info['StackStatus']
-
-    @property
-    def outputs(self) -> str:
-        return self._stack_info['Outputs']
-
-    def delete(self):
-        return self._cf.delete_stack(StackName=self.stack_id)
-
-    def wait_stack_created(self, delay=30):
-        waiter = self._cf.get_waiter('stack_create_complete')
-        waiter.wait(StackName=self.stack_id, WaiterConfig={'Delay': delay})
-
-    def wait_stack_updated(self, delay=30):
-        waiter = self._cf.get_waiter('stack_update_complete')
-        waiter.wait(StackName=self.stack_id, WaiterConfig={'Delay': delay})
-
-    def wait_stack_deleted(self, delay=30):
-        waiter = self._cf.get_waiter('stack_delete_complete')
-        waiter.wait(StackName=self.stack_id, WaiterConfig={'Delay': delay})
-
-    def wait_status_changed(self, waiting_status, resource_messages, resource_success_status,
-                            output: AbstractOutputWriter, delay=5):
-        current_status = waiting_status
-        stack = None
-
-        resource_messages = iter(resource_messages) if resource_messages else None
-        resource_name = None
-
-        while current_status == waiting_status:
-            sleep(delay)
-
-            # display resource creation progress
-            if resource_messages:
-                try:
-                    stack_resources = self._cf.list_stack_resources(StackName=self.stack_id)
-                except EndpointConnectionError:
-                    output.write('Connection problem')
-                    continue
-
-                resource_statuses = dict([(row['LogicalResourceId'], row['ResourceStatus'])
-                                          for row in stack_resources['StackResourceSummaries']])
-
-                while (resource_name is None) or (resource_name and
-                                                  resource_statuses.get(resource_name, '') == resource_success_status):
-                    (resource_name, resource_msg) = next(resource_messages, (False, False))
-                    if resource_name:
-                        output.write('- %s...' % resource_msg)
-
-            # get the latest status of the stack
-            try:
-                stack = self.get_by_name(self._cf, self.stack_id)
-            except EndpointConnectionError:
-                output.write('Connection problem')
-                continue
-
-            current_status = stack.status
-
-        return stack
diff --git a/spotty/providers/gcp/deployment/dm_templates/__init__.py b/spotty/providers/aws/cfn_templates/__init__.py
similarity index 100%
rename from spotty/providers/gcp/deployment/dm_templates/__init__.py
rename to spotty/providers/aws/cfn_templates/__init__.py
diff --git a/spotty/providers/gcp/deployment/project_resources/__init__.py b/spotty/providers/aws/cfn_templates/instance/__init__.py
similarity index 100%
rename from spotty/providers/gcp/deployment/project_resources/__init__.py
rename to spotty/providers/aws/cfn_templates/instance/__init__.py
diff --git a/spotty/providers/aws/cfn_templates/instance/data/files/tmux.conf b/spotty/providers/aws/cfn_templates/instance/data/files/tmux.conf
new file mode 100644
index 0000000..cf4e060
--- /dev/null
+++ b/spotty/providers/aws/cfn_templates/instance/data/files/tmux.conf
@@ -0,0 +1,1 @@
+bind-key x kill-pane
diff --git a/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/01_prepare_instance.sh b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/01_prepare_instance.sh
new file mode 100644
index 0000000..6389c34
--- /dev/null
+++ b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/01_prepare_instance.sh
@@ -0,0 +1,17 @@
+#!/bin/bash -xe
+
+cfn-signal -e 0 --stack ${AWS::StackName} --region ${AWS::Region} --resource PreparingInstanceSignal
+
+# install AWS CLI
+update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
+apt-get update && apt-get install -y python3-pip
+pip3 install -U awscli
+aws configure set default.region ${AWS::Region}
+
+# install jq
+apt-get install -y jq
+
+# create an alias to connect to the docker container
+CONTAINER_BASH_ALIAS=container
+echo "alias $CONTAINER_BASH_ALIAS=\"{{CONTAINER_BASH_SCRIPT_PATH}}\"" >> /home/ubuntu/.bashrc
+echo "alias $CONTAINER_BASH_ALIAS=\"{{CONTAINER_BASH_SCRIPT_PATH}}\"" >> /root/.bashrc
diff --git a/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/02_mount_volumes.sh b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/02_mount_volumes.sh
new file mode 100644
index 0000000..7ef3d9b
--- /dev/null
+++ b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/02_mount_volumes.sh
@@ -0,0 +1,35 @@
+#!/bin/bash -xe
+
+cfn-signal -e 0 --stack ${AWS::StackName} --region ${AWS::Region} --resource MountingVolumesSignal
+
+# mount volumes
+DEVICE_LETTERS=(f g h i j k l m n o p)
+MOUNT_DIRS=({{{MOUNT_DIRS}}})
+
+for i in ${!!MOUNT_DIRS[*]}
+do
+  MOUNT_DIR=${!MOUNT_DIRS[$i]}
+  DEVICE=/dev/xvd${!DEVICE_LETTERS[$i]}
+
+  # NVMe EBS volume (see: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/nvme-ebs-volumes.html)
+  if [ ! -b $DEVICE ]; then
+    VOLUME_ID=$(cfn-get-metadata --stack spotty-instance-my-project-i1 --region us-east-2 --resource VolumeAttachment${!DEVICE_LETTERS[$i]^} -k VolumeId)
+    DEVICE=$(lsblk -o NAME,SERIAL -dpJ | jq -rc ".blockdevices[] | select(.serial == \"${!VOLUME_ID//-}\") | .name")
+    if [ -z "$DEVICE" ]; then
+      echo "Device for the volume $VOLUME_ID not found"
+      exit 1
+    fi
+  fi
+
+  blkid -o value -s TYPE $DEVICE || mkfs -t ext4 $DEVICE
+  mkdir -p $MOUNT_DIR
+  mount $DEVICE $MOUNT_DIR
+  chmod 777 $MOUNT_DIR
+  resize2fs $DEVICE
+done
+
+# create directories for temporary volumes
+{{#TMP_VOLUME_DIRS}}
+mkdir -p {{PATH}}
+chmod 777 {{PATH}}
+{{/TMP_VOLUME_DIRS}}
diff --git a/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/03_set_docker_root.sh b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/03_set_docker_root.sh
new file mode 100644
index 0000000..a56ca97
--- /dev/null
+++ b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/03_set_docker_root.sh
@@ -0,0 +1,10 @@
+#!/bin/bash -xe
+
+cfn-signal -e 0 --stack ${AWS::StackName} --region ${AWS::Region} --resource SettingDockerRootSignal
+
+# change docker data root directory
+if [ -n "${DockerDataRootDirectory}" ]; then
+  jq '. + { "data-root": "${DockerDataRootDirectory}" }' /etc/docker/daemon.json > /tmp/docker_daemon.json \
+    && mv /tmp/docker_daemon.json /etc/docker/daemon.json
+  service docker restart
+fi
diff --git a/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/04_sync_project.sh b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/04_sync_project.sh
new file mode 100644
index 0000000..9947412
--- /dev/null
+++ b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/04_sync_project.sh
@@ -0,0 +1,16 @@
+#!/bin/bash -xe
+
+cfn-signal -e 0 --stack ${AWS::StackName} --region ${AWS::Region} --resource SyncingProjectSignal
+
+# create a project directory
+if [ -n "${HostProjectDirectory}" ]; then
+  mkdir -p 777 ${HostProjectDirectory}
+  chmod 777 ${HostProjectDirectory}
+
+  if [ -d '${HostProjectDirectory}/lost+found' ]; then
+    chmod 777 '${HostProjectDirectory}/lost+found'
+  fi
+fi
+
+# sync project files from S3 bucket to the instance
+{{{SYNC_PROJECT_CMD}}}
diff --git a/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/05_run_instance_startup_commands.sh b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/05_run_instance_startup_commands.sh
new file mode 100644
index 0000000..8aa65ab
--- /dev/null
+++ b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/05_run_instance_startup_commands.sh
@@ -0,0 +1,5 @@
+#!/bin/bash -xe
+
+cfn-signal -e 0 --stack ${AWS::StackName} --region ${AWS::Region} --resource RunningInstanceStartupCommandsSignal
+
+/bin/bash -xe {{INSTANCE_STARTUP_SCRIPTS_DIR}}/instance_startup_commands.sh
diff --git a/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/user_data.sh b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/user_data.sh
new file mode 100644
index 0000000..098aab2
--- /dev/null
+++ b/spotty/providers/aws/cfn_templates/instance/data/startup_scripts/user_data.sh
@@ -0,0 +1,37 @@
+#!/bin/bash -x
+
+cd /root || exit 1
+
+# install CloudFormation tools if they are not installed yet
+if [ ! -e /usr/local/bin/cfn-init ]; then
+  apt-get update
+  apt-get install -y python-setuptools
+  mkdir -p aws-cfn-bootstrap-latest
+  curl https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.tar.gz | tar xz -C aws-cfn-bootstrap-latest --strip-components 1
+  python2 -m easy_install aws-cfn-bootstrap-latest
+fi
+
+# prepare the instance and run Docker container
+cfn-init \
+  --stack ${AWS::StackName} \
+  --region ${AWS::Region} \
+  --resource InstanceLaunchTemplate \
+  -c init \
+  -v
+
+STACK_CREATED=$?
+
+# uplooad cfn-init logs to the bucket
+if [ $STACK_CREATED -ne 0 ]; then
+  STACK_ID=${AWS::StackId}
+  STACK_UUID=${!STACK_ID##*/}
+
+  aws s3 cp /var/log/cfn-init-cmd.log ${LogsS3Path}/$STACK_UUID/cfn-init-cmd.log
+fi
+
+# send signal that the Docker container is ready or failed
+cfn-signal \
+  -e $STACK_CREATED \
+  --stack ${AWS::StackName} \
+  --region ${AWS::Region} \
+  --resource DockerReadyWaitCondition
diff --git a/spotty/providers/aws/cfn_templates/instance/data/template.yaml b/spotty/providers/aws/cfn_templates/instance/data/template.yaml
new file mode 100644
index 0000000..b999b4b
--- /dev/null
+++ b/spotty/providers/aws/cfn_templates/instance/data/template.yaml
@@ -0,0 +1,168 @@
+Description: Spotty EC2 Instance
+Parameters:
+  VpcId:
+    Description: VPC ID
+    Type: AWS::EC2::VPC::Id
+  InstanceProfileArn:
+    Description: Instance Profile ARN
+    Type: String
+  InstanceType:
+    Description: Instance type
+    Type: String
+  KeyName:
+    Description: EC2 Key Pair name
+    Type: AWS::EC2::KeyPair::KeyName
+  ImageId:
+    Description: AMI ID
+    Type: AWS::EC2::Image::Id
+  RootVolumeSize:
+    Description: Root volume size
+    Type: String
+  DockerDataRootDirectory:
+    Description: Docker data root directory
+    Type: String
+    Default: ''
+  InstanceNameTag:
+    Description: Name for the instance
+    Type: String
+    Default: ''
+  HostProjectDirectory:
+    Description: Destination directory for the project
+    Type: String
+    Default: ''
+  LogsS3Path:
+    Description: An S3 path where logs will be uploaded in the case of a failure
+    Type: String
+    Default: ''
+Resources:
+  Instance:
+    Type: AWS::EC2::Instance
+    Properties:
+      LaunchTemplate:
+        LaunchTemplateId: !Ref InstanceLaunchTemplate
+        Version: !GetAtt InstanceLaunchTemplate.LatestVersionNumber
+
+  InstanceLaunchTemplate:
+    Type: AWS::EC2::LaunchTemplate
+    Properties:
+      LaunchTemplateData:
+        InstanceType: !Ref InstanceType
+        ImageId: !Ref ImageId
+        KeyName: !Ref KeyName
+        EbsOptimized: 'false'
+        TagSpecifications:
+          - ResourceType: instance
+            Tags:
+              - Key: Name
+                Value: !Ref InstanceNameTag
+        IamInstanceProfile:
+          Arn: !Ref InstanceProfileArn
+        SecurityGroupIds:
+          - !Ref InstanceSecurityGroup
+        InstanceInitiatedShutdownBehavior: terminate
+        InstanceMarketOptions:
+          MarketType: spot
+          SpotOptions:
+            SpotInstanceType: one-time
+            InstanceInterruptionBehavior: terminate
+        BlockDeviceMappings:
+          - DeviceName: /dev/sda1
+            Ebs:
+              VolumeSize: !Ref RootVolumeSize
+              DeleteOnTermination: true
+        UserData: ''
+    Metadata:
+      'AWS::CloudFormation::Init': {}
+
+  InstanceSecurityGroup:
+    Type: AWS::EC2::SecurityGroup
+    Properties:
+      VpcId: !Ref VpcId
+      GroupDescription: Spotty security group
+      SecurityGroupEgress:
+        - CidrIp: 0.0.0.0/0
+          IpProtocol: -1
+          FromPort: 0
+          ToPort: 65535
+        - CidrIpv6: ::/0
+          IpProtocol: -1
+          FromPort: 0
+          ToPort: 65535
+      SecurityGroupIngress:
+        - CidrIp: 0.0.0.0/0
+          IpProtocol: tcp
+          FromPort: 22
+          ToPort: 22
+        - CidrIpv6: ::/0
+          IpProtocol: tcp
+          FromPort: 22
+          ToPort: 22
+
+  PreparingInstanceSignal:
+    Type: AWS::CloudFormation::WaitCondition
+    DependsOn: Instance
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT30M
+
+  MountingVolumesSignal:
+    Type: AWS::CloudFormation::WaitCondition
+    DependsOn: Instance
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT30M
+
+  SettingDockerRootSignal:
+    Type: AWS::CloudFormation::WaitCondition
+    DependsOn: Instance
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT60M
+
+  SyncingProjectSignal:
+    Type: AWS::CloudFormation::WaitCondition
+    DependsOn: Instance
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT60M
+
+  RunningInstanceStartupCommandsSignal:
+    Type: AWS::CloudFormation::WaitCondition
+    DependsOn: Instance
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT60M
+
+  BuildingDockerImageSignal:
+    Type: AWS::CloudFormation::WaitCondition
+    DependsOn: Instance
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT60M
+
+  StartingContainerSignal:
+    Type: AWS::CloudFormation::WaitCondition
+    DependsOn: Instance
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT60M
+
+  RunningContainerStartupCommandsSignal:
+    Type: AWS::CloudFormation::WaitCondition
+    DependsOn: Instance
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT60M
+
+  DockerReadyWaitCondition:
+    Type: AWS::CloudFormation::WaitCondition
+    DependsOn: Instance
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT30M
+
+Outputs:
+  InstanceId:
+    Value: !Ref Instance
+  AvailabilityZone:
+    Value: !GetAtt Instance.AvailabilityZone
diff --git a/spotty/providers/aws/cfn_templates/instance/start_container_script.py b/spotty/providers/aws/cfn_templates/instance/start_container_script.py
new file mode 100644
index 0000000..142f120
--- /dev/null
+++ b/spotty/providers/aws/cfn_templates/instance/start_container_script.py
@@ -0,0 +1,22 @@
+from spotty.deployment.container.docker.scripts.start_container_script import StartContainerScript
+
+
+class StartContainerScriptWithCfnSignals(StartContainerScript):
+
+    @staticmethod
+    def _get_signal_command(resource_name: str):
+        return 'cfn-signal -e 0 --stack $_{AWS::StackName} --region $_{AWS::Region} --resource ' + resource_name
+
+    def _partials(self) -> dict:
+        return {
+            'before_image_build': self._get_signal_command('BuildingDockerImageSignal'),
+            'before_container_run': self._get_signal_command('StartingContainerSignal'),
+            'before_startup_commands': self._get_signal_command('RunningContainerStartupCommandsSignal'),
+        }
+
+    def render(self, print_trace: bool = False) -> str:
+        content = super().render(print_trace=print_trace)
+        content = content.replace('${', '${!')
+        content = content.replace('$_{', '${')
+
+        return content
diff --git a/spotty/providers/aws/cfn_templates/instance/template.py b/spotty/providers/aws/cfn_templates/instance/template.py
new file mode 100644
index 0000000..3ed8651
--- /dev/null
+++ b/spotty/providers/aws/cfn_templates/instance/template.py
@@ -0,0 +1,390 @@
+from typing import List
+import os
+import chevron
+import yaml
+from cfn_tools import CfnYamlLoader, CfnYamlDumper
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.config.tmp_dir_volume import TmpDirVolume
+from spotty.config.validation import is_subdir
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.deployment.container.docker.docker_commands import DockerCommands
+from spotty.deployment.container.docker.scripts.container_bash_script import ContainerBashScript
+from spotty.deployment.abstract_cloud_instance.file_structure import INSTANCE_SPOTTY_TMP_DIR, CONTAINER_BASH_SCRIPT_PATH, \
+    INSTANCE_STARTUP_SCRIPTS_DIR
+from spotty.providers.aws.cfn_templates.instance.start_container_script import StartContainerScriptWithCfnSignals
+from spotty.providers.aws.helpers.ami import get_ami
+from spotty.providers.aws.helpers.vpc import get_vpc_id
+from spotty.providers.aws.resources.snapshot import Snapshot
+from spotty.providers.aws.resources.volume import Volume
+from spotty.providers.aws.config.instance_config import InstanceConfig
+from spotty.providers.aws.config.ebs_volume import EbsVolume
+from spotty.providers.aws.helpers.logs import get_logs_s3_path
+
+
+def prepare_instance_template(ec2, instance_config: InstanceConfig, docker_commands: DockerCommands,
+                              availability_zone: str, sync_project_cmd: str, output: AbstractOutputWriter):
+    """Prepares CloudFormation template to run a Spot Instance."""
+
+    # read and update CF template
+    with open(os.path.join(os.path.dirname(__file__), 'data', 'template.yaml')) as f:
+        template = yaml.load(f, Loader=CfnYamlLoader)
+
+    # get volume resources and updated availability zone
+    volume_resources = _get_volume_resources(ec2, instance_config.volumes, output)
+
+    # add volume resources to the template
+    template['Resources'].update(volume_resources)
+
+    # set availability zone
+    if availability_zone:
+        template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['Placement'] = {
+            'AvailabilityZone': availability_zone,
+        }
+        output.write('- availability zone: %s' % availability_zone)
+    else:
+        output.write('- availability zone: auto')
+
+    # set subnet
+    if instance_config.subnet_id:
+        template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['NetworkInterfaces'] = [
+            {
+                'SubnetId': instance_config.subnet_id,
+                'DeviceIndex': 0,
+                'Groups': template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData'][
+                    'SecurityGroupIds'],
+            }]
+        del template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['SecurityGroupIds']
+
+    # add ports to the security group
+    for port in instance_config.ports:
+        if port != 22:
+            template['Resources']['InstanceSecurityGroup']['Properties']['SecurityGroupIngress'] += [{
+                'CidrIp': '0.0.0.0/0',
+                'IpProtocol': 'tcp',
+                'FromPort': port,
+                'ToPort': port,
+            }, {
+                'CidrIpv6': '::/0',
+                'IpProtocol': 'tcp',
+                'FromPort': port,
+                'ToPort': port,
+            }]
+
+    if instance_config.is_spot_instance:
+        # set maximum price
+        if instance_config.max_price:
+            template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData'] \
+                ['InstanceMarketOptions']['SpotOptions']['MaxPrice'] = instance_config.max_price
+
+        output.write('- maximum Spot Instance price: %s'
+                     % (('%.04f' % instance_config.max_price) if instance_config.max_price else 'on-demand'))
+    else:
+        # run on-demand instance
+        del template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData'][
+            'InstanceMarketOptions']
+        output.write('- on-demand instance')
+
+    # set the user data script
+    template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['UserData'] = {
+        'Fn::Base64': {
+            'Fn::Sub': _read_template_file(os.path.join('startup_scripts', 'user_data.sh')),
+        },
+    }
+
+    # run sync command as a non-root user
+    if instance_config.container_config.run_as_host_user:
+        sync_project_cmd = 'sudo -u %s %s' % (instance_config.user, sync_project_cmd)
+
+    # get mount directories
+    mount_dirs = [volume.mount_dir for volume in instance_config.volumes if isinstance(volume, EbsVolume)]
+
+    # set CloudFormation configs
+    cfn_init_configs = [
+        {
+            'name': 'prepare_instance',
+            'files': {
+                INSTANCE_STARTUP_SCRIPTS_DIR + '/01_prepare_instance.sh': {
+                    'owner': 'ubuntu',
+                    'group': 'ubuntu',
+                    'mode': '000755',
+                    'content': {
+                        'Fn::Sub': _read_template_file(os.path.join('startup_scripts', '01_prepare_instance.sh'), {
+                            'INSTANCE_SPOTTY_TMP_DIR': INSTANCE_SPOTTY_TMP_DIR,
+                            'CONTAINER_BASH_SCRIPT_PATH': CONTAINER_BASH_SCRIPT_PATH,
+                        }),
+                    },
+                },
+                CONTAINER_BASH_SCRIPT_PATH: {
+                    'owner': 'ubuntu',
+                    'group': 'ubuntu',
+                    'mode': '000755',
+                    'content': ContainerBashScript(docker_commands).render(),
+                },
+                '/home/ubuntu/.tmux.conf': {
+                    'owner': 'ubuntu',
+                    'group': 'ubuntu',
+                    'mode': '000644',
+                    'content': {
+                        'Fn::Sub': _read_template_file(os.path.join('files', 'tmux.conf')),
+                    },
+                },
+            },
+            'command': INSTANCE_STARTUP_SCRIPTS_DIR + '/01_prepare_instance.sh',
+        },
+        {
+            'name': 'mount_volumes',
+            'files': {
+                INSTANCE_STARTUP_SCRIPTS_DIR + '/02_mount_volumes.sh': {
+                    'owner': 'ubuntu',
+                    'group': 'ubuntu',
+                    'mode': '000755',
+                    'content': {
+                        'Fn::Sub': _read_template_file(os.path.join('startup_scripts', '02_mount_volumes.sh'), {
+                            'MOUNT_DIRS': ('"%s"' % '" "'.join(mount_dirs)) if mount_dirs else '',
+                            'TMP_VOLUME_DIRS': [{'PATH': volume.host_path} for volume in instance_config.volumes
+                                                if isinstance(volume, TmpDirVolume)],
+                        }),
+                    },
+                },
+            },
+            'command': INSTANCE_STARTUP_SCRIPTS_DIR + '/02_mount_volumes.sh',
+        },
+        {
+            'name': 'set_docker_root',
+            'files': {
+                INSTANCE_STARTUP_SCRIPTS_DIR + '/03_set_docker_root.sh': {
+                    'owner': 'ubuntu',
+                    'group': 'ubuntu',
+                    'mode': '000755',
+                    'content': {
+                        'Fn::Sub': _read_template_file(os.path.join('startup_scripts', '03_set_docker_root.sh')),
+                    },
+                },
+            },
+            'command': INSTANCE_STARTUP_SCRIPTS_DIR + '/03_set_docker_root.sh',
+        },
+        {
+            'name': 'sync_project',
+            'files': {
+                INSTANCE_STARTUP_SCRIPTS_DIR + '/04_sync_project.sh': {
+                    'owner': 'ubuntu',
+                    'group': 'ubuntu',
+                    'mode': '000755',
+                    'content': {
+                        'Fn::Sub': _read_template_file(os.path.join('startup_scripts', '04_sync_project.sh'), {
+                            'SYNC_PROJECT_CMD': sync_project_cmd,
+                        }),
+                    },
+                },
+            },
+            'command': INSTANCE_STARTUP_SCRIPTS_DIR + '/04_sync_project.sh',
+        },
+        {
+            'name': 'run_instance_startup_commands',
+            'files': {
+                INSTANCE_STARTUP_SCRIPTS_DIR + '/05_run_instance_startup_commands.sh': {
+                    'owner': 'ubuntu',
+                    'group': 'ubuntu',
+                    'mode': '000755',
+                    'content': {
+                        'Fn::Sub': _read_template_file(
+                            os.path.join('startup_scripts', '05_run_instance_startup_commands.sh'), {
+                                'INSTANCE_STARTUP_SCRIPTS_DIR': INSTANCE_STARTUP_SCRIPTS_DIR,
+                            }),
+                    },
+                },
+                INSTANCE_STARTUP_SCRIPTS_DIR + '/instance_startup_commands.sh': {
+                    'owner': 'ubuntu',
+                    'group': 'ubuntu',
+                    'mode': '000644',
+                    'content': instance_config.commands or '#',
+                },
+            },
+            'command': INSTANCE_STARTUP_SCRIPTS_DIR + '/05_run_instance_startup_commands.sh',
+        },
+        {
+            'name': 'start_container',
+            'files': {
+                INSTANCE_STARTUP_SCRIPTS_DIR + '/06_start_container.sh': {
+                    'owner': 'ubuntu',
+                    'group': 'ubuntu',
+                    'mode': '000755',
+                    'content': {
+                        'Fn::Sub': StartContainerScriptWithCfnSignals(docker_commands).render(print_trace=True),
+                    },
+                },
+            },
+            'command': INSTANCE_STARTUP_SCRIPTS_DIR + '/06_start_container.sh',
+        },
+    ]
+
+    template['Resources']['InstanceLaunchTemplate']['Metadata']['AWS::CloudFormation::Init']['configSets'] = {
+        'init': [config['name'] for config in cfn_init_configs],
+    }
+
+    for config in cfn_init_configs:
+        template['Resources']['InstanceLaunchTemplate']['Metadata']['AWS::CloudFormation::Init'][config['name']] = {
+            'files': config.get('files', {}),
+            'commands': {
+                config['name']: {
+                    'command': config['command'],
+                }
+            },
+        }
+
+    return yaml.dump(template, Dumper=CfnYamlDumper)
+
+
+def _read_template_file(filename: str, params: dict = None):
+    with open(os.path.join(os.path.dirname(__file__), 'data', filename)) as f:
+        content = f.read()
+
+    if params:
+        content = chevron.render(content, params)
+
+    return content
+
+
+def _get_volume_attachment_resource(volume_id, device_name):
+    attachment_resource = {
+        'Type': 'AWS::EC2::VolumeAttachment',
+        'Properties': {
+            'Device': device_name,
+            'InstanceId': {'Ref': 'Instance'},
+            'VolumeId': volume_id if isinstance(volume_id, str) else dict(volume_id),  # avoid YAML aliases
+        },
+        'Metadata': {
+            'Device': device_name,
+            'VolumeId': volume_id if isinstance(volume_id, str) else dict(volume_id),  # avoid YAML aliases
+        },
+    }
+
+    return attachment_resource
+
+
+def _get_volume_resource(ec2, volume: EbsVolume, output: AbstractOutputWriter):
+    # new volume will be created
+    volume_resource = {
+        'Type': 'AWS::EC2::Volume',
+        'DeletionPolicy': 'Retain',
+        'Properties': {
+            'AvailabilityZone': {'Fn::GetAtt': ['Instance', 'AvailabilityZone']},
+            'Tags': [{
+                'Key': 'Name',
+                'Value': volume.ec2_volume_name,
+            }],
+            'VolumeType': volume.type,
+        },
+    }
+
+    # check if the snapshot exists and restore the volume from it
+    snapshot = Snapshot.get_by_name(ec2, volume.ec2_volume_name)
+    if snapshot:
+        # volume will be restored from the snapshot
+        # check size of the volume
+        if volume.size and (volume.size < snapshot.size):
+            raise ValueError('Specified size for the "%s" volume (%dGB) is less than size of the '
+                             'snapshot (%dGB).'
+                             % (volume.name, volume.size, snapshot.size))
+
+        # set snapshot ID
+        volume_resource['Properties']['SnapshotId'] = snapshot.snapshot_id
+
+        output.write('- volume "%s" will be restored from the snapshot' % volume.ec2_volume_name)
+
+    else:
+        # empty volume will be created, check that the size is specified
+        if not volume.size:
+            raise ValueError('Size for the new volume is required.')
+
+        output.write('- volume "%s" will be created' % volume.ec2_volume_name)
+
+    # set size of the volume
+    if volume.size:
+        volume_resource['Properties']['Size'] = volume.size
+
+    # set a name for the new volume
+    volume_resource['Properties']['Tags'] = [{'Key': 'Name', 'Value': volume.ec2_volume_name}]
+
+    return volume_resource
+
+
+def _get_volume_resources(ec2, volumes: List[AbstractInstanceVolume], output: AbstractOutputWriter):
+    resources = {}
+
+    # ending letters for the devices (see: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/device_naming.html)
+    device_letters = 'fghijklmnop'
+
+    # create and attach volumes
+    for i, volume in enumerate(volumes):
+        if isinstance(volume, EbsVolume):
+            device_letter = device_letters[i]
+
+            ec2_volume = Volume.get_by_name(ec2, volume.ec2_volume_name)
+            if ec2_volume:
+                # check if the volume is available
+                if not ec2_volume.is_available():
+                    raise ValueError('EBS volume "%s" is not available (state: %s).'
+                                     % (volume.ec2_volume_name, ec2_volume.state))
+
+                # check size of the volume
+                if volume.size and (volume.size != ec2_volume.size):
+                    raise ValueError('Specified size for the "%s" volume (%dGB) doesn\'t match the size of the '
+                                     'existing volume (%dGB).' % (volume.name, volume.size, ec2_volume.size))
+
+                output.write('- volume "%s" (%s) will be attached' % (ec2_volume.name, ec2_volume.volume_id))
+
+                volume_id = ec2_volume.volume_id
+            else:
+                # create Volume resource
+                vol_resource_name = 'Volume' + device_letter.upper()
+                vol_resource = _get_volume_resource(ec2, volume, output)
+                resources[vol_resource_name] = vol_resource
+
+                volume_id = {'Ref': vol_resource_name}
+
+            # create VolumeAttachment resource
+            vol_attachment_resource_name = 'VolumeAttachment' + device_letter.upper()
+            device_name = '/dev/sd' + device_letter
+            vol_attachment_resource = _get_volume_attachment_resource(volume_id, device_name)
+            resources[vol_attachment_resource_name] = vol_attachment_resource
+
+    return resources
+
+
+def get_template_parameters(ec2, instance_config: InstanceConfig, instance_profile_arn: str, bucket_name: str,
+                            key_pair_name: str, output: AbstractOutputWriter):
+    # get AMI
+    ami = get_ami(ec2, instance_config.ami_id, instance_config.ami_name)
+    output.write('- AMI: "%s" (%s)' % (ami.name, ami.image_id))
+
+    # check root volume size
+    root_volume_size = instance_config.root_volume_size
+    if root_volume_size and root_volume_size < ami.size:
+        raise ValueError('Root volume size cannot be less than the size of AMI (%dGB).' % ami.size)
+    elif not root_volume_size:
+        # if a root volume size is not specified, make it 5GB larger than the AMI size
+        root_volume_size = ami.size + 5
+
+    # print info about the Docker data root
+    ebs_volumes = [volume for volume in instance_config.volumes if isinstance(volume, EbsVolume)]
+    if instance_config.docker_data_root:
+        docker_data_volume_name = [volume.name for volume in ebs_volumes
+                                   if is_subdir(instance_config.docker_data_root, volume.mount_dir)][0]
+        output.write('- Docker data will be stored on the "%s" volume' % docker_data_volume_name)
+
+    # create stack
+    parameters = {
+        'VpcId': get_vpc_id(ec2, instance_config.subnet_id),
+        'InstanceProfileArn': instance_profile_arn,
+        'InstanceType': instance_config.instance_type,
+        'KeyName': key_pair_name,
+        'ImageId': ami.image_id,
+        'RootVolumeSize': str(root_volume_size),
+        'DockerDataRootDirectory': instance_config.docker_data_root,
+        'InstanceNameTag': instance_config.ec2_instance_name,
+        'HostProjectDirectory': instance_config.host_project_dir,
+        'LogsS3Path': get_logs_s3_path(bucket_name, instance_config.name),
+    }
+
+    return parameters
diff --git a/spotty/providers/gcp/gcp_resources/__init__.py b/spotty/providers/aws/cfn_templates/instance_profile/__init__.py
similarity index 100%
rename from spotty/providers/gcp/gcp_resources/__init__.py
rename to spotty/providers/aws/cfn_templates/instance_profile/__init__.py
diff --git a/spotty/providers/aws/deployment/cf_templates/data/instance_profile.yaml b/spotty/providers/aws/cfn_templates/instance_profile/data/template.yaml
similarity index 100%
rename from spotty/providers/aws/deployment/cf_templates/data/instance_profile.yaml
rename to spotty/providers/aws/cfn_templates/instance_profile/data/template.yaml
diff --git a/spotty/providers/aws/deployment/cf_templates/instance_profile_template.py b/spotty/providers/aws/cfn_templates/instance_profile/template.py
similarity index 79%
rename from spotty/providers/aws/deployment/cf_templates/instance_profile_template.py
rename to spotty/providers/aws/cfn_templates/instance_profile/template.py
index f0ad290..fc5214b 100644
--- a/spotty/providers/aws/deployment/cf_templates/instance_profile_template.py
+++ b/spotty/providers/aws/cfn_templates/instance_profile/template.py
@@ -3,7 +3,7 @@
 
 
 def prepare_instance_profile_template(managed_policy_arns: list):
-    with open(os.path.join(os.path.dirname(__file__), 'data', 'instance_profile.yaml')) as f:
+    with open(os.path.join(os.path.dirname(__file__), 'data', 'template.yaml')) as f:
         content = f.read()
 
     parameters = {
diff --git a/spotty/providers/aws/commands/create_ami.py b/spotty/providers/aws/commands/create_ami.py
deleted file mode 100644
index 195b3f5..0000000
--- a/spotty/providers/aws/commands/create_ami.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from argparse import Namespace, ArgumentParser
-import subprocess
-from spotty.commands.abstract_config_command import AbstractConfigCommand
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.helpers.ssh import get_ssh_command
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
-from spotty.providers.aws.instance_manager import InstanceManager
-
-
-class CreateAmiCommand(AbstractConfigCommand):
-
-    name = 'create-ami'
-    description = 'Create AMI with NVIDIA Docker'
-
-    def configure(self, parser: ArgumentParser):
-        super().configure(parser)
-        parser.add_argument('--debug-mode', action='store_true', help='Don\'t terminate the instance on failure')
-
-    def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
-        # check that it's an AWS instance
-        if not isinstance(instance_manager, InstanceManager):
-            raise ValueError('Instance "%s" is not an AWS instance.' % instance_manager.instance_config.name)
-
-        deployment = instance_manager.ami_deployment
-
-        try:
-            deployment.deploy(args.debug_mode, output)
-        finally:
-            if args.debug_mode:
-                ip_address = deployment.get_ip_address()
-                if ip_address:
-                    ssh_command = get_ssh_command(ip_address, instance_manager.ssh_port, instance_manager.ssh_user,
-                                                  instance_manager.ssh_key_path, 'tmux')
-
-                    output.write('\nUse the following command to connect to the instance:\n'
-                                 '  %s\n' % subprocess.list2cmdline(ssh_command))
diff --git a/spotty/providers/aws/commands/delete_ami.py b/spotty/providers/aws/commands/delete_ami.py
deleted file mode 100644
index 88090b3..0000000
--- a/spotty/providers/aws/commands/delete_ami.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from argparse import Namespace
-from spotty.commands.abstract_config_command import AbstractConfigCommand
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
-from spotty.providers.aws.instance_manager import InstanceManager
-
-
-class DeleteAmiCommand(AbstractConfigCommand):
-
-    name = 'delete-ami'
-    description = 'Delete AMI with NVIDIA Docker'
-
-    def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
-        # check that it's an AWS instance
-        if not isinstance(instance_manager, InstanceManager):
-            raise ValueError('Instance "%s" is not an AWS instance.' % instance_manager.instance_config.name)
-
-        instance_manager.ami_deployment.delete(output)
diff --git a/spotty/providers/aws/commands/spot_prices.py b/spotty/providers/aws/commands/spot_prices.py
index a5db4ef..3b17da9 100644
--- a/spotty/providers/aws/commands/spot_prices.py
+++ b/spotty/providers/aws/commands/spot_prices.py
@@ -2,13 +2,13 @@
 import boto3
 from spotty.commands.abstract_command import AbstractCommand
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.aws.helpers.spot_prices import get_spot_prices
+from spotty.providers.aws.helpers.instance_prices import get_spot_prices
 
 
 class SpotPricesCommand(AbstractCommand):
 
     name = 'spot-prices'
-    description = 'Get spot instance prices for particular instance type across all regions'
+    description = 'Get Spot Instance prices for an instance type across all AWS regions or within a specific region.'
 
     def configure(self, parser: ArgumentParser):
         super().configure(parser)
diff --git a/spotty/providers/aws/deployment/project_resources/ebs_volume.py b/spotty/providers/aws/config/ebs_volume.py
similarity index 59%
rename from spotty/providers/aws/deployment/project_resources/ebs_volume.py
rename to spotty/providers/aws/config/ebs_volume.py
index 657319d..cfc3bf6 100644
--- a/spotty/providers/aws/deployment/project_resources/ebs_volume.py
+++ b/spotty/providers/aws/config/ebs_volume.py
@@ -1,32 +1,23 @@
-from spotty.deployment.abstract_instance_volume import AbstractInstanceVolume
-from spotty.providers.aws.aws_resources.snapshot import Snapshot
-from spotty.providers.aws.aws_resources.volume import Volume
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
 from spotty.providers.aws.config.validation import validate_ebs_volume_parameters
 
 
 class EbsVolume(AbstractInstanceVolume):
 
-    DP_CREATE_SNAPSHOT = 'create_snapshot'
-    DP_UPDATE_SNAPSHOT = 'update_snapshot'
-    DP_RETAIN = 'retain'
-    DP_DELETE = 'delete'
+    TYPE_NAME = 'EBS'
 
-    def __init__(self, ec2, volume_config: dict, project_name: str, instance_name: str):
-        self._ec2 = ec2
-        self._name = volume_config['name']
-        self._params = validate_ebs_volume_parameters(volume_config['parameters'])
+    DP_CREATE_SNAPSHOT = 'CreateSnapshot'
+    DP_UPDATE_SNAPSHOT = 'UpdateSnapshot'
+    DP_RETAIN = 'Retain'
+    DP_DELETE = 'Delete'
 
-        self._project_name = project_name
-        self._instance_name = instance_name
+    def _validate_volume_parameters(self, params: dict) -> dict:
+        return validate_ebs_volume_parameters(params)
 
     @property
     def title(self):
         return 'EBS volume'
 
-    @property
-    def name(self):
-        return self._name
-
     @property
     def size(self) -> int:
         return self._params['size']
@@ -59,6 +50,7 @@ def ec2_volume_name(self) -> str:
 
     @property
     def mount_dir(self) -> str:
+        """A directory where the volume will be mounted on the host OS."""
         if self._params['mountDir']:
             mount_dir = self._params['mountDir']
         else:
@@ -66,8 +58,7 @@ def mount_dir(self) -> str:
 
         return mount_dir
 
-    def get_ec2_volume(self) -> Volume:
-        return Volume.get_by_name(self._ec2, self.ec2_volume_name)
-
-    def get_snapshot(self) -> Snapshot:
-        return Snapshot.get_by_name(self._ec2, self.ec2_volume_name)
+    @property
+    def host_path(self) -> str:
+        """A path on the host OS that will be mounted to the container."""
+        return self.mount_dir
diff --git a/spotty/providers/aws/config/instance_config.py b/spotty/providers/aws/config/instance_config.py
index 30177ca..fca01fc 100644
--- a/spotty/providers/aws/config/instance_config.py
+++ b/spotty/providers/aws/config/instance_config.py
@@ -1,16 +1,36 @@
-from spotty.config.abstract_instance_config import AbstractInstanceConfig
+from typing import List
+from spotty.config.abstract_instance_config import AbstractInstanceConfig, VolumeMount
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
 from spotty.providers.aws.config.validation import validate_instance_parameters
+from spotty.providers.aws.config.ebs_volume import EbsVolume
+
 
-VOLUME_TYPE_EBS = 'ebs'
 DEFAULT_AMI_NAME = 'SpottyAMI'
 
 
 class InstanceConfig(AbstractInstanceConfig):
 
-    def __init__(self, config: dict):
-        super().__init__(config)
+    def _validate_instance_params(self, params: dict) -> dict:
+        return validate_instance_parameters(params)
+
+    def _get_instance_volumes(self) -> List[AbstractInstanceVolume]:
+        volumes = []
+        for volume_config in self._params['volumes']:
+            volume_type = volume_config['type']
+            if volume_type == EbsVolume.TYPE_NAME:
+                volumes.append(EbsVolume(volume_config, self.project_config.project_name, self.name))
+            else:
+                raise ValueError('AWS volume type "%s" not supported.' % volume_type)
+
+        return volumes
 
-        self._params = validate_instance_parameters(self._params)
+    @property
+    def user(self):
+        return 'ubuntu'
+
+    @property
+    def ec2_instance_name(self) -> str:
+        return '%s-%s' % (self.project_config.project_name.lower(), self.name.lower())
 
     @property
     def region(self) -> str:
@@ -29,16 +49,12 @@ def instance_type(self) -> str:
         return self._params['instanceType']
 
     @property
-    def on_demand(self) -> bool:
-        return self._params['onDemandInstance']
+    def is_spot_instance(self) -> bool:
+        return self._params['spotInstance']
 
     @property
     def ami_name(self) -> str:
-        return self._params['amiName'] if self._params['amiName'] else DEFAULT_AMI_NAME
-
-    @property
-    def has_ami_name(self) -> bool:
-        return bool(self._params['amiName'])
+        return self._params['amiName']
 
     @property
     def ami_id(self) -> str:
@@ -48,6 +64,10 @@ def ami_id(self) -> str:
     def root_volume_size(self) -> int:
         return self._params['rootVolumeSize']
 
+    @property
+    def ports(self) -> List[int]:
+        return list(set(self._params['ports']))
+
     @property
     def max_price(self) -> float:
         return self._params['maxPrice']
@@ -55,3 +75,7 @@ def max_price(self) -> float:
     @property
     def managed_policy_arns(self) -> list:
         return self._params['managedPolicyArns']
+    
+    @property
+    def instance_profile_arn(self) -> str:
+        return self._params['instanceProfileArn']
diff --git a/spotty/providers/aws/config/validation.py b/spotty/providers/aws/config/validation.py
index 4a25aed..5b3eb2b 100644
--- a/spotty/providers/aws/config/validation.py
+++ b/spotty/providers/aws/config/validation.py
@@ -1,16 +1,17 @@
+import os
 from schema import Schema, Optional, And, Regex, Or, Use
-from spotty.config.validation import validate_config, get_instance_parameters_schema
+from spotty.config.validation import validate_config, get_instance_parameters_schema, has_prefix
 
 
 def validate_instance_parameters(params: dict):
-    from spotty.providers.aws.config.instance_config import VOLUME_TYPE_EBS
+    from spotty.providers.aws.config.ebs_volume import EbsVolume
 
     instance_parameters = {
         'region': And(str, Regex(r'^[a-z0-9-]+$')),
         Optional('availabilityZone', default=''): And(str, Regex(r'^[a-z0-9-]+$')),
         Optional('subnetId', default=''): And(str, Regex(r'^subnet-[a-z0-9]+$')),
         'instanceType': str,
-        Optional('onDemandInstance', default=False): bool,
+        Optional('spotInstance', default=False): bool,
         Optional('amiName', default=None): And(str, len, Regex(r'^[\w\(\)\[\]\s\.\/\'@-]{3,128}$')),
         Optional('amiId', default=None): And(str, len, Regex(r'^ami-[a-z0-9]+$')),
         Optional('rootVolumeSize', default=0): And(Or(int, str), Use(str),
@@ -20,6 +21,7 @@ def validate_instance_parameters(params: dict):
                                                        error='"rootVolumeSize" should be greater than 0 or should '
                                                              'not be specified.'),
                                                    ),
+        Optional('ports', default=[]): [And(int, lambda x: 0 < x < 65536)],
         Optional('maxPrice', default=0): And(Or(float, int, str), Use(str),
                                              Regex(r'^\d+(\.\d{1,6})?$', error='Incorrect value for "maxPrice".'),
                                              Use(float),
@@ -27,59 +29,57 @@ def validate_instance_parameters(params: dict):
                                                                         'should  not be specified.'),
                                              ),
         Optional('managedPolicyArns', default=[]): [str],
+        Optional('instanceProfileArn', default=None): str,
     }
 
     volumes_checks = [
         And(lambda x: len(x) < 12, error='Maximum 11 volumes are supported at the moment.'),
+        And(lambda x: not has_prefix([(volume['parameters']['mountDir'] + '/') for volume in x
+                                      if volume['parameters'].get('mountDir')]),
+            error='Mount directories cannot be prefixes for each other.'),
     ]
 
     instance_checks = [
-        And(lambda x: not (x['onDemandInstance'] and x['maxPrice']),
-            error='"maxPrice" cannot be specified for on-demand instances.'),
+        And(lambda x: not (x['maxPrice'] and not x['spotInstance']),
+            error='"maxPrice" can be specified only for spot instances.'),
         And(lambda x: not (x['amiName'] and x['amiId']),
             error='"amiName" and "amiId" parameters cannot be used together.'),
     ]
 
-    schema = get_instance_parameters_schema(instance_parameters, VOLUME_TYPE_EBS, instance_checks, volumes_checks)
+    schema = get_instance_parameters_schema(instance_parameters, EbsVolume.TYPE_NAME, instance_checks, volumes_checks)
 
     return validate_config(schema, params)
 
 
 def validate_ebs_volume_parameters(params: dict):
-    from spotty.providers.aws.deployment.project_resources.ebs_volume import EbsVolume
+    from spotty.providers.aws.config.ebs_volume import EbsVolume
+
+    old_deletion_policies_map = {
+        'create_snapshot': EbsVolume.DP_CREATE_SNAPSHOT,
+        'update_snapshot': EbsVolume.DP_UPDATE_SNAPSHOT,
+        'retain': EbsVolume.DP_RETAIN,
+        'delete': EbsVolume.DP_DELETE,
+    }
 
     schema = Schema({
         Optional('volumeName', default=''): And(str, Regex(r'^[\w-]{1,255}$')),
-        Optional('mountDir', default=''): str,  # all the checks happened in the base configuration
+        Optional('mountDir', default=''): And(
+            str,
+            And(os.path.isabs, error='Use absolute paths in the "mountDir" parameters'),
+            Use(lambda x: x.rstrip('/'))
+        ),
         Optional('size', default=0): And(int, lambda x: x > 0),
         # TODO: add the "iops" parameter to support the "io1" EBS volume type
         Optional('type', default='gp2'): lambda x: x in ['gp2', 'sc1', 'st1', 'standard'],
-        Optional('deletionPolicy', default=EbsVolume.DP_CREATE_SNAPSHOT): And(
+        Optional('deletionPolicy', default=EbsVolume.DP_RETAIN): And(
             str,
             lambda x: x in [EbsVolume.DP_CREATE_SNAPSHOT,
                             EbsVolume.DP_UPDATE_SNAPSHOT,
                             EbsVolume.DP_RETAIN,
-                            EbsVolume.DP_DELETE], error='Incorrect value for "deletionPolicy".'
+                            EbsVolume.DP_DELETE] + list(old_deletion_policies_map.keys()),
+            Use(lambda x: old_deletion_policies_map.get(x, x)),
+            error='Incorrect value for "deletionPolicy".',
         ),
     })
 
     return validate_config(schema, params)
-
-
-def is_gpu_instance(instance_type: str):
-    # a list of GPU instance from: https://aws.amazon.com/ec2/instance-types/#Accelerated_Computing
-    return instance_type in [
-        'p2.xlarge', 'p2.8xlarge', 'p2.16xlarge',
-        'p3.2xlarge', 'p3.8xlarge', 'p3.16xlarge', 'p3dn.24xlarge',
-        'g3s.xlarge', 'g3.4xlarge', 'g3.8xlarge', 'g3.16xlarge',
-    ]
-
-
-def is_nitro_instance(instance_type):
-    # a list of Nitro-based instances from:
-    # https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-types.html#ec2-nitro-instances
-    nitro_prefixes = ['a1', 'c5', 'c5d', 'c5n', 'i3en', 'm5', 'm5a', 'm5ad', 'm5d', 'r5', 'r5a', 'r5ad', 'r5d',
-                         't3', 't3a', 'z1d']
-    nitro_types = ['p3dn.24xlarge', 'i3.metal', 'u-6tb1.metal', 'u-9tb1.metal', 'u-12tb1.metal']
-
-    return any(instance_type.startswith(prefix + '.') for prefix in nitro_prefixes) or instance_type in nitro_types
diff --git a/spotty/providers/aws/data_transfer.py b/spotty/providers/aws/data_transfer.py
new file mode 100644
index 0000000..11c69e8
--- /dev/null
+++ b/spotty/providers/aws/data_transfer.py
@@ -0,0 +1,74 @@
+import logging
+import subprocess
+from spotty.deployment.abstract_cloud_instance.abstract_data_transfer import AbstractDataTransfer
+from spotty.providers.aws.helpers.s3_sync import get_s3_sync_command, check_aws_installed
+
+
+class DataTransfer(AbstractDataTransfer):
+
+    def __init__(self, local_project_dir: str, host_project_dir: str, sync_filters: list, instance_name: str,
+                 region: str):
+        super().__init__(local_project_dir, host_project_dir, sync_filters, instance_name)
+
+        self._region = region
+
+    @property
+    def scheme_name(self) -> str:
+        return 's3'
+
+    def upload_local_to_bucket(self, bucket_name: str, dry_run: bool = False):
+        """Uploads files from local to the bucket."""
+        # check AWS CLI is installed
+        check_aws_installed()
+
+        # sync the project with S3, deleted files will be deleted from S3
+        local_cmd = get_s3_sync_command(self._local_project_dir, self._get_bucket_project_path(bucket_name),
+                                        region=self._region, filters=self._sync_filters, delete=True, dry_run=dry_run)
+
+        # execute the command locally
+        logging.debug('Local sync command: ' + local_cmd)
+        exit_code = subprocess.call(local_cmd, shell=True)
+        if exit_code != 0:
+            raise ValueError('Failed to upload the project files to the S3 bucket.')
+
+    def download_bucket_to_local(self, bucket_name: str, download_filters: list):
+        """Downloads files from the bucket to local."""
+        # check AWS CLI is installed
+        check_aws_installed()
+
+        # download files from S3 bucket to local
+        local_cmd = get_s3_sync_command(self._get_bucket_downloads_path(bucket_name), self._local_project_dir,
+                                        region=self._region, filters=download_filters, exact_timestamp=True)
+
+        # execute the command locally
+        logging.debug('Local sync command: ' + local_cmd)
+        exit_code = subprocess.call(local_cmd, shell=True)
+        if exit_code != 0:
+            raise ValueError('Failed to download files from the S3 bucket to local')
+
+    def get_download_bucket_to_instance_command(self, bucket_name: str, use_sudo: bool = False) -> str:
+        """A remote command to download files from the bucket to the instance."""
+        remote_cmd = get_s3_sync_command(self._get_bucket_project_path(bucket_name), self._host_project_dir,
+                                         region=self._region, filters=self._sync_filters, exact_timestamp=True)
+        if use_sudo:
+            remote_cmd = 'sudo ' + remote_cmd
+
+        return remote_cmd
+
+    def get_upload_instance_to_bucket_command(self, bucket_name: str, download_filters: list, use_sudo: bool = False,
+                                              dry_run: bool = False) -> str:
+        """A remote command to upload files from the instance to the bucket.
+
+        It uses a temporary S3 directory that is unique for the instance. This
+        directory keeps all downloaded from the instance files to sync only changed
+        files with local.
+        """
+
+        # "sudo" should be called with the "-i" flag to use the root environment, so aws-cli will read
+        # the config file from the root home directory
+        remote_cmd = get_s3_sync_command(self._host_project_dir, self._get_bucket_downloads_path(bucket_name),
+                                         region=self._region, filters=download_filters, delete=True, dry_run=dry_run)
+        if use_sudo:
+            remote_cmd = 'sudo ' + remote_cmd
+
+        return remote_cmd
diff --git a/spotty/providers/aws/deletion_policies.py b/spotty/providers/aws/deletion_policies.py
new file mode 100644
index 0000000..32212a0
--- /dev/null
+++ b/spotty/providers/aws/deletion_policies.py
@@ -0,0 +1,105 @@
+from typing import List
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.providers.aws.resources.snapshot import Snapshot
+from spotty.providers.aws.resources.volume import Volume
+from spotty.providers.aws.config.ebs_volume import EbsVolume
+
+
+def apply_deletion_policies(ec2, volumes: List[AbstractInstanceVolume], output: AbstractOutputWriter):
+    """Applies deletion policies to the EBS volumes."""
+
+    # get volumes
+    ebs_volumes = [volume for volume in volumes if isinstance(volume, EbsVolume)]
+
+    # no volumes
+    if not ebs_volumes:
+        output.write('- no EBS volumes configured')
+        return
+
+    # apply deletion policies
+    wait_snapshots = []
+    for volume in ebs_volumes:
+        # get EC2 volume
+        try:
+            ec2_volume = Volume.get_by_name(ec2, volume.ec2_volume_name)
+        except Exception as e:
+            output.write('- volume "%s" not found. Error: %s' % (volume.ec2_volume_name, str(e)))
+            continue
+
+        if not ec2_volume:
+            output.write('- volume "%s" not found' % volume.ec2_volume_name)
+            continue
+
+        if not ec2_volume.is_available():
+            output.write('- volume "%s" is not available (state: %s)'
+                         % (volume.ec2_volume_name, ec2_volume.state))
+            continue
+
+        # apply deletion policies
+        if volume.deletion_policy == EbsVolume.DP_RETAIN:
+            # do nothing
+            output.write('- volume "%s" is retained' % ec2_volume.name)
+
+        elif volume.deletion_policy == EbsVolume.DP_DELETE:
+            # delete EBS volume
+            _delete_ec2_volume(ec2_volume, output)
+
+        elif volume.deletion_policy == EbsVolume.DP_CREATE_SNAPSHOT \
+                or volume.deletion_policy == EbsVolume.DP_UPDATE_SNAPSHOT:
+            try:
+                # rename a previous snapshot
+                prev_snapshot = Snapshot.get_by_name(ec2, volume.ec2_volume_name)
+                if prev_snapshot:
+                    prev_snapshot.rename('%s-%d' % (prev_snapshot.name, prev_snapshot.creation_time))
+
+                output.write('- creating a snapshot for the volume "%s"...' % ec2_volume.name)
+
+                # create a new snapshot
+                new_snapshot = ec2_volume.create_snapshot()
+
+                # delete the EBS volume and a previous snapshot only after a new snapshot will be created
+                wait_snapshots.append({
+                    'new_snapshot': new_snapshot,
+                    'prev_snapshot': prev_snapshot,
+                    'ec2_volume': ec2_volume,
+                    'deletion_policy': volume.deletion_policy,
+                })
+            except Exception as e:
+                output.write('- snapshot for the volume "%s" was not created. Error: %s'
+                             % (volume.ec2_volume_name, str(e)))
+
+        else:
+            raise ValueError('Unsupported deletion policy: "%s".' % volume.deletion_policy)
+
+    # wait until all snapshots will be created
+    for resources in wait_snapshots:
+        try:
+            resources['new_snapshot'].wait_snapshot_completed()
+            output.write('- snapshot for the volume "%s" was created' % resources['new_snapshot'].name)
+        except Exception as e:
+            output.write('- snapshot "%s" was not created. Error: %s' % (resources['new_snapshot'].name, str(e)))
+            continue
+
+        # delete a previous snapshot if it's the "update_snapshot" deletion policy
+        if (resources['deletion_policy'] == EbsVolume.DP_UPDATE_SNAPSHOT) and resources['prev_snapshot']:
+            _delete_snapshot(resources['prev_snapshot'], output)
+
+        # delete the EBS volume
+        _delete_ec2_volume(resources['ec2_volume'], output)
+
+
+def _delete_ec2_volume(ec2_volume: Volume, output: AbstractOutputWriter):
+    try:
+        ec2_volume.delete()
+        output.write('- volume "%s" was deleted' % ec2_volume.name)
+    except Exception as e:
+        output.write('- volume "%s" was not deleted. Error: %s' % (ec2_volume.name, str(e)))
+
+
+def _delete_snapshot(snapshot: Snapshot, output: AbstractOutputWriter):
+    try:
+        snapshot.delete()
+        output.write('- previous snapshot "%s" was deleted' % snapshot.name)
+    except Exception as e:
+        output.write('- previous snapshot "%s" was not deleted. Error: %s' % (snapshot.name, str(e)))
diff --git a/spotty/providers/aws/deployment/abstract_aws_deployment.py b/spotty/providers/aws/deployment/abstract_aws_deployment.py
deleted file mode 100644
index c14b6ee..0000000
--- a/spotty/providers/aws/deployment/abstract_aws_deployment.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from abc import ABC, abstractmethod
-import boto3
-from spotty.providers.aws.aws_resources.subnet import Subnet
-from spotty.providers.aws.aws_resources.vpc import Vpc
-from spotty.providers.aws.config.instance_config import InstanceConfig
-from spotty.providers.aws.deployment.project_resources.key_pair import KeyPairResource
-
-
-class AbstractAwsDeployment(ABC):
-
-    def __init__(self, project_name: str, instance_config: InstanceConfig):
-        self._project_name = project_name
-        self._instance_config = instance_config
-        self._ec2 = boto3.client('ec2', region_name=instance_config.region)
-
-    @property
-    def instance_config(self) -> InstanceConfig:
-        return self._instance_config
-
-    @property
-    @abstractmethod
-    def ec2_instance_name(self) -> str:
-        """Name for EC2 instance."""
-        raise NotImplementedError
-
-    @property
-    def key_pair(self) -> KeyPairResource:
-        return KeyPairResource(self._project_name, self.instance_config.region, self.instance_config.provider_name)
-
-    def get_vpc_id(self) -> str:
-        """Returns VPC ID that should be used for deployment."""
-        if self.instance_config.subnet_id:
-            vpc_id = Subnet.get_by_id(self._ec2, self.instance_config.subnet_id).vpc_id
-        else:
-            default_vpc = Vpc.get_default_vpc(self._ec2)
-            if not default_vpc:
-                raise ValueError('Default VPC not found')
-
-            vpc_id = default_vpc.vpc_id
-
-        return vpc_id
diff --git a/spotty/providers/aws/deployment/ami_deployment.py b/spotty/providers/aws/deployment/ami_deployment.py
deleted file mode 100644
index ef348a1..0000000
--- a/spotty/providers/aws/deployment/ami_deployment.py
+++ /dev/null
@@ -1,110 +0,0 @@
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.aws.aws_resources.image import Image
-from spotty.providers.aws.deployment.abstract_aws_deployment import AbstractAwsDeployment
-from spotty.providers.aws.aws_resources.instance import Instance
-from spotty.providers.aws.deployment.cf_templates.ami_template import prepare_ami_template
-from spotty.providers.aws.deployment.checks import check_az_and_subnet, check_max_price
-from spotty.providers.aws.deployment.project_resources.ami_stack import AmiStackResource
-from spotty.providers.aws.config.validation import is_gpu_instance
-
-
-class AmiDeployment(AbstractAwsDeployment):
-
-    # version of the AMI stack
-    VERSION = '1.1.0'
-
-    @property
-    def ec2_instance_name(self) -> str:
-        return 'spotty-ami-%s' % self.instance_config.ami_name.lower()
-
-    @property
-    def stack(self):
-        return AmiStackResource(self.instance_config.ami_name, self.instance_config.region)
-
-    def deploy(self, debug_mode: bool, output: AbstractOutputWriter):
-        # check that the "amiId" parameter is not set
-        if self.instance_config.ami_id:
-            raise ValueError('The "amiId" parameter cannot be used for creating an AMI.')
-
-        # check that it's a GPU instance type
-        instance_type = self.instance_config.instance_type
-        if not is_gpu_instance(instance_type):
-            raise ValueError('"%s" is not a GPU instance' % instance_type)
-
-        # check that an image with this name doesn't exist yet
-        ami = Image.get_by_name(self._ec2, self.instance_config.ami_name)
-        if ami:
-            raise ValueError('AMI with the name "%s" already exists.' % self.instance_config.ami_name)
-
-        # check availability zone and subnet
-        check_az_and_subnet(self._ec2, self.instance_config.region, self.instance_config.availability_zone,
-                            self.instance_config.subnet_id)
-
-        # check the maximum price for a spot instance
-        availability_zone = self.instance_config.availability_zone
-        check_max_price(self._ec2, self.instance_config.instance_type, self.instance_config.on_demand,
-                        self.instance_config.max_price, availability_zone)
-
-        # prepare CF template
-        subnet_id = self.instance_config.subnet_id
-        on_demand = self.instance_config.on_demand
-        template = prepare_ami_template(availability_zone, subnet_id, debug_mode, on_demand)
-
-        # create stack
-        parameters = self._get_template_parameters(debug_mode)
-        self.stack.create_stack(template, parameters, debug_mode, output)
-
-    def _get_template_parameters(self, debug_mode: bool = False):
-        parameters = {
-            'ImageVersion': self.VERSION,
-            'VpcId': self.get_vpc_id(),
-            'InstanceType': self.instance_config.instance_type,
-            'ImageName': self.instance_config.ami_name,
-            'InstanceNameTag': self.ec2_instance_name,
-            'NvidiaDriverVersion': '410',
-            'DockerCEVersion': '19.03.5',
-            'ContainerdIOVersion': '1.2.10-3',
-            'NvidiaContainerToolkitVersion': '1.0.5-1',
-        }
-
-        if debug_mode:
-            parameters['DebugMode'] = 'true'
-            parameters['KeyName'] = self.key_pair.get_or_create_key()  # get or create a key pair
-
-        return parameters
-
-    def delete(self, output: AbstractOutputWriter):
-        # check that the "amiId" parameter is not set
-        if self.instance_config.ami_id:
-            raise ValueError('The "amiId" parameter cannot be used for deleting an AMI.')
-
-        # check if the AMI stack exists
-        stack_id = None
-        if not self.stack.get_stack():
-            # try to get the stack ID from the AMI tags (for older versions of Spotty)
-            ami = Image.get_by_name(self._ec2, self.instance_config.ami_name)
-            if not ami:
-                raise ValueError('AMI with the name "%s" not found.' % self.instance_config.ami_name)
-
-            stack_id = ami.get_tag_value('spotty:stack-id')
-            if not stack_id:
-                raise ValueError('AMI "%s" wasn\'t created by Spotty.' % self.instance_config.ami_name)
-
-        # ask user to confirm the deletion
-        confirm = input('AMI "%s" will be deleted.\n'
-                        'Type "y" to confirm: ' % self.instance_config.ami_name)
-        if confirm != 'y':
-            output.write('You didn\'t confirm the operation.')
-            return
-
-        self.stack.delete_stack(output, stack_id=stack_id)
-
-    def get_ip_address(self):
-        instance = Instance.get_by_stack_name(self._ec2, self.stack.name)
-        if not instance:
-            return None
-
-        if self._instance_config.local_ssh_port:
-            return '127.0.0.1'
-
-        return instance.public_ip_address
diff --git a/spotty/providers/aws/deployment/cf_templates/ami_template.py b/spotty/providers/aws/deployment/cf_templates/ami_template.py
deleted file mode 100644
index 9defa01..0000000
--- a/spotty/providers/aws/deployment/cf_templates/ami_template.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import os
-import yaml
-from cfn_tools import CfnYamlDumper, CfnYamlLoader
-
-
-def prepare_ami_template(availability_zone: str, subnet_id: str, debug_mode: bool = False, on_demand: bool = False):
-    """Prepares CloudFormation template to run a Spot Instance."""
-
-    # read and update CF template
-    with open(os.path.join(os.path.dirname(__file__), 'data', 'ami.yaml')) as f:
-        template = yaml.load(f, Loader=CfnYamlLoader)
-
-    # remove the key parameter if it's not a debug mode
-    if not debug_mode:
-        del template['Parameters']['KeyName']
-        del template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['KeyName']
-
-    # set availability zone
-    if availability_zone:
-        template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['Placement'] = {
-            'AvailabilityZone': availability_zone,
-        }
-
-    # set subnet
-    if subnet_id:
-        template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['NetworkInterfaces'] = [
-            {
-                'SubnetId': subnet_id,
-                'DeviceIndex': 0,
-                'Groups': template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData'][
-                    'SecurityGroupIds'],
-            }]
-        del template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['SecurityGroupIds']
-
-    # run on-demand instance
-    if on_demand:
-        del template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData'][
-            'InstanceMarketOptions']
-
-    return yaml.dump(template, Dumper=CfnYamlDumper)
diff --git a/spotty/providers/aws/deployment/cf_templates/data/ami.yaml b/spotty/providers/aws/deployment/cf_templates/data/ami.yaml
deleted file mode 100644
index bdba91d..0000000
--- a/spotty/providers/aws/deployment/cf_templates/data/ami.yaml
+++ /dev/null
@@ -1,475 +0,0 @@
-Description: Spotty NVIDIA Docker AMI
-Parameters:
-  ImageVersion:
-    Description: Version of the AMI
-    Type: String
-  VpcId:
-    Description: VPC ID
-    Type: AWS::EC2::VPC::Id
-  InstanceType:
-    Description: Instance type
-    Type: String
-  ImageName:
-    Description: AMI name
-    Type: String
-  KeyName:
-    Description: EC2 Key Pair name
-    Type: AWS::EC2::KeyPair::KeyName
-  InstanceNameTag:
-    Description: Name for the instance
-    Type: String
-    Default: ''
-  NvidiaDriverVersion:
-    Description: NVIDIA Driver Version
-    Type: String
-    Default: ''
-  DockerCEVersion:
-    Description: Docker CE Version
-    Type: String
-    Default: ''
-  ContainerdIOVersion:
-    Description: containerd.io Version
-    Type: String
-    Default: ''
-  NvidiaContainerToolkitVersion:
-    Description: NVIDIA Container Toolkit Version
-    Type: String
-    Default: ''
-  DebugMode:
-    Description: Debug mode
-    Type: String
-    Default: false
-    AllowedValues: [true, false]
-Resources:
-  FindBaseAMI:
-    Type: Custom::AMI
-    Properties:
-      ServiceToken: !GetAtt FindBaseAMIFunction.Arn
-      Region": !Ref 'AWS::Region'
-  FindBaseAMIFunction:
-    Type: AWS::Lambda::Function
-    Properties:
-      Handler: index.handler
-      Role: !GetAtt FindBaseAMILambdaExecutionRole.Arn
-      Code:
-        ZipFile: !Sub |
-          var aws=require("aws-sdk");var response=require('cfn-response');exports.handler=function(event,context){console.log("request received:\n"+JSON.stringify(event));var physicalId=event.PhysicalResourceId;function success(data){data=data||{};console.log('SUCCESS:\n',data);return response.send(event,context,response.SUCCESS,data,physicalId)}
-          function failed(err){console.log('FAILED:\n',err);return response.send(event,context,response.FAILED,err,physicalId)}
-          if(event.RequestType=='Delete'){console.log('Delete request is ignored');return success()}
-          var ec2=new aws.EC2({region:event.ResourceProperties.Region});ec2.describeImages({Filters:[{Name:"name",Values:['ubuntu/images/hvm-ssd/ubuntu-xenial-16.04-amd64*']}],Owners:['099720109477']}).promise().then((data)=>{console.log('Number of found images: ',data.Images.length);var images=data.Images;images.sort(function(x,y){return y.Name.localeCompare(x.Name)});var amiId=!1;for(var j=0;j<images.length;j++){if(isBeta(images[j].Name))continue;amiId=images[j].ImageId;break}
-          if(!amiId){throw new Error('AMI not found')}
-          console.log('Found AMI ID='+amiId)
-          physicalId=amiId;success()}).catch((err)=>failed(err))};function isBeta(imageName){return imageName.toLowerCase().indexOf("beta")>-1||imageName.toLowerCase().indexOf(".rc")>-1}
-      Runtime: nodejs12.x
-      Timeout: 30
-  FindBaseAMILambdaExecutionRole:
-    Type: AWS::IAM::Role
-    Properties:
-      AssumeRolePolicyDocument:
-        Version: '2012-10-17'
-        Statement:
-          - Effect: 'Allow'
-            Principal:
-              Service:
-                - 'lambda.amazonaws.com'
-            Action:
-              - 'sts:AssumeRole'
-      Path: '/'
-      Policies:
-        - PolicyName: FindBaseAMIPolicy
-          PolicyDocument:
-            Version: '2012-10-17'
-            Statement:
-              - Effect: 'Allow'
-                Action:
-                  - 'logs:CreateLogGroup'
-                  - 'logs:CreateLogStream'
-                  - 'logs:PutLogEvents'
-                Resource: 'arn:aws:logs:*:*:*'
-              - Effect: 'Allow'
-                Action:
-                  - 'ec2:DescribeImages'
-                Resource: '*'
-  DeleteAMIFunctionRetention:
-    Type: Custom::LogsRetention
-    DependsOn: FindBaseAMI
-    Properties:
-      ServiceToken: !GetAtt SetLogsRetentionFunction.Arn
-      LogGroupName: !Join ['', ['/aws/lambda/', !Ref FindBaseAMIFunction]]
-      RetentionInDays: 1
-
-  Instance:
-    Type: AWS::EC2::Instance
-    Properties:
-      LaunchTemplate:
-        LaunchTemplateId: !Ref InstanceLaunchTemplate
-        Version: !GetAtt InstanceLaunchTemplate.LatestVersionNumber
-
-  InstanceLaunchTemplate:
-    Type: AWS::EC2::LaunchTemplate
-    Properties:
-      LaunchTemplateData:
-        InstanceType: !Ref InstanceType
-        ImageId: !Ref FindBaseAMI
-        KeyName: !Ref KeyName
-        EbsOptimized: 'false'
-        TagSpecifications:
-          - ResourceType: instance
-            Tags:
-              - Key: Name
-                Value: !Ref InstanceNameTag
-        IamInstanceProfile:
-          Arn: !GetAtt InstanceProfile.Arn
-        SecurityGroupIds:
-          - !Ref InstanceSecurityGroup
-        InstanceInitiatedShutdownBehavior: terminate
-        InstanceMarketOptions:
-          MarketType: spot
-          SpotOptions:
-            SpotInstanceType: one-time
-            InstanceInterruptionBehavior: terminate
-        BlockDeviceMappings:
-          - DeviceName: /dev/sda1
-            Ebs:
-              VolumeType: gp2
-              DeleteOnTermination: true
-        UserData:
-          'Fn::Base64': !Sub |
-            #!/bin/bash -x
-
-            # stop daily updates for apt (fix for the issue when the cloud-init fails to install packages
-            # because the "/var/lib/dpkg/lock" file is locked)
-            systemctl disable --now apt-daily{,-upgrade}.{timer,service}
-
-            # install CloudFormation tools
-            apt-get update
-            apt-get -y install python-setuptools
-            mkdir aws-cfn-bootstrap-latest
-            curl https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.tar.gz | tar xz -C aws-cfn-bootstrap-latest --strip-components 1
-            easy_install aws-cfn-bootstrap-latest
-
-            # install NVIDIA Docker
-            /usr/local/bin/cfn-init \
-              --stack ${AWS::StackName} \
-              --resource InstanceLaunchTemplate \
-              --region ${AWS::Region} \
-              -c init \
-              -v
-
-            INIT_EXIT_CODE=$?
-
-            # send signal that the instance is ready
-            /usr/local/bin/cfn-signal \
-              -e $INIT_EXIT_CODE \
-              --stack ${AWS::StackName} \
-              --region ${AWS::Region} \
-              --resource InstanceReadyWaitCondition
-
-            if [[ $INIT_EXIT_CODE -ne 0 ]] ; then
-              exit $INIT_EXIT_CODE
-            fi
-
-            # don't create an AMI in the debug mode
-            if [ "${DebugMode}" = "true" ]; then
-              /usr/local/bin/cfn-signal \
-                -e 1 \
-                --stack ${AWS::StackName} \
-                --region ${AWS::Region} \
-                --resource AMICreatedWaitCondition
-              exit 1
-            fi
-
-            # create the AMI
-            /usr/local/bin/cfn-init \
-              --stack ${AWS::StackName} \
-              --resource InstanceLaunchTemplate \
-              --region ${AWS::Region} \
-              -c ami \
-              -v
-
-            AMI_EXIT_CODE=$?
-
-            # send signal that the AMI is created
-            /usr/local/bin/cfn-signal \
-              -e $AMI_EXIT_CODE \
-              --stack ${AWS::StackName} \
-              --region ${AWS::Region} \
-              --resource AMICreatedWaitCondition
-
-            if [[ $AMI_EXIT_CODE -ne 0 ]] ; then
-              exit $AMI_EXIT_CODE
-            fi
-
-            # shutdown the instance
-            shutdown -h now
-
-    Metadata:
-      'AWS::CloudFormation::Init':
-        configSets:
-          init:
-            - init_config
-          ami:
-            - ami_config
-        init_config:
-          files:
-            /tmp/spotty/ami/init.sh:
-              content: !Sub |
-                #!/bin/bash -e
-
-                # install Docker CE
-                apt-get update
-                apt-get install -y \
-                  apt-transport-https \
-                  ca-certificates \
-                  curl \
-                  gnupg-agent \
-                  software-properties-common
-                curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
-                add-apt-repository \
-                   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
-                   $(lsb_release -cs) \
-                   stable"
-                apt-get update
-                apt-get install -y \
-                  docker-ce=5:${DockerCEVersion}~3-0~ubuntu-xenial \
-                  docker-ce-cli=5:${DockerCEVersion}~3-0~ubuntu-xenial \
-                  containerd.io=${ContainerdIOVersion}
-
-                # install NVIDIA driver
-                apt-key adv --fetch-keys  http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1604/x86_64/7fa2af80.pub
-                bash -c 'echo "deb http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1604/x86_64 /" > /etc/apt/sources.list.d/cuda.list'
-                mkdir /usr/lib/nvidia  # a fix to install the driver, see: https://devtalk.nvidia.com/default/topic/1032456/linux/nvidia-387-26-for-ubuntu-16-04-package-broken-/
-                apt-get update
-                apt-get install -y nvidia-${NvidiaDriverVersion}
-
-                # install nvidia-docker2
-                curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | apt-key add -
-                distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
-                curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | \
-                  tee /etc/apt/sources.list.d/nvidia-docker.list
-                apt-get update
-                apt-get install -y nvidia-container-toolkit=${NvidiaContainerToolkitVersion}
-
-                # reload the Docker daemon configuration
-                systemctl restart docker
-
-                # test NVIDIA Docker runtime
-                docker run --gpus all --rm nvidia/cuda:10.1-base-ubuntu16.04 nvidia-smi
-                docker rmi $(docker images -q)
-              mode: '000755'
-              owner: ubuntu
-              group: ubuntu
-          commands:
-            run_init:
-              command: '/bin/bash -xe /tmp/spotty/ami/init.sh'
-        ami_config:
-          files:
-            /tmp/spotty/ami/create_ami.sh:
-              content: !Sub |
-                #!/bin/bash -e
-
-                # install AWS CLI
-                apt-get install -y awscli
-                aws configure set default.region ${AWS::Region}
-
-                # install jq
-                apt-get install -y jq
-
-                # flush the disk buffers
-                sync
-
-                # create AMI
-                INSTANCE_ID=`curl http://169.254.169.254/latest/meta-data/instance-id`
-                IMAGE_ID=`aws ec2 create-image --instance-id $INSTANCE_ID --name "${ImageName}" --description "Spotty AMI v${ImageVersion}" --no-reboot | jq -r '.ImageId'`
-                aws ec2 create-tags --resources $IMAGE_ID \
-                  --tags Key=spotty:stack-id,Value=${AWS::StackId} "Key=Name,Value=NVIDIA Docker"
-                aws ec2 wait image-available --image-ids $IMAGE_ID
-              mode: '000755'
-              owner: ubuntu
-              group: ubuntu
-          commands:
-            create_ami:
-              command: '/bin/bash -xe /tmp/spotty/ami/create_ami.sh'
-
-  InstanceProfile:
-    Type: AWS::IAM::InstanceProfile
-    Properties:
-      Path: '/'
-      Roles:
-        - Ref: InstanceRole
-  InstanceRole:
-    Type: AWS::IAM::Role
-    Properties:
-      AssumeRolePolicyDocument:
-        Version: '2012-10-17'
-        Statement:
-          - Effect: Allow
-            Principal:
-              Service:
-                - ec2.amazonaws.com
-            Action:
-              - sts:AssumeRole
-      Path: '/'
-      Policies:
-        - PolicyName: AMICreationPolicy
-          PolicyDocument:
-            Version: '2012-10-17'
-            Statement:
-            - Effect: Allow
-              Action:
-              - 'ec2:DescribeImages'
-              - 'ec2:CreateImage'
-              - 'ec2:CreateTags'
-              Resource: ['*']
-
-  InstanceSecurityGroup:
-    Type: AWS::EC2::SecurityGroup
-    Properties:
-      VpcId: !Ref VpcId
-      GroupDescription: Spotty AMI Security Group
-      SecurityGroupEgress:
-        - CidrIp: 0.0.0.0/0
-          IpProtocol: -1
-          FromPort: 0
-          ToPort: 65535
-        - CidrIpv6: ::/0
-          IpProtocol: -1
-          FromPort: 0
-          ToPort: 65535
-      SecurityGroupIngress:
-        - CidrIp: 0.0.0.0/0
-          IpProtocol: tcp
-          FromPort: 22
-          ToPort: 22
-        - CidrIpv6: ::/0
-          IpProtocol: tcp
-          FromPort: 22
-          ToPort: 22
-
-  InstanceReadyWaitCondition:
-    Type: AWS::CloudFormation::WaitCondition
-    DependsOn: Instance
-    CreationPolicy:
-      ResourceSignal:
-        Timeout: PT20M
-
-  AMICreatedWaitCondition:
-    Type: AWS::CloudFormation::WaitCondition
-    DependsOn: Instance
-    CreationPolicy:
-      ResourceSignal:
-        Timeout: PT30M
-
-  DeleteAMI:
-    Type: Custom::AMI
-    DependsOn: AMICreatedWaitCondition
-    Properties:
-      ServiceToken: !GetAtt DeleteAMIFunction.Arn
-  DeleteAMIFunction:
-    Type: AWS::Lambda::Function
-    Properties:
-      Handler: index.handler
-      Role: !GetAtt DeleteAMILambdaExecutionRole.Arn
-      Code:
-        ZipFile: !Sub |
-          var aws=require('aws-sdk');var response=require('cfn-response');exports.handler=function(event,context){console.log('Request received:\n',JSON.stringify(event));var physicalId=event.PhysicalResourceId;function success(data){data=data||{};console.log('SUCCESS:\n',data);return response.send(event,context,response.SUCCESS,data,physicalId)}
-          function failed(err){console.log('FAILED:\n',err);return response.send(event,context,response.FAILED,err,physicalId)}
-          var ec2=new aws.EC2({region:event.ResourceProperties.Region});if(event.RequestType=='Create'||event.RequestType=='Update'){ec2.describeImages({Filters:[{Name:'tag:spotty:stack-id',Values:[event.StackId]}]}).promise().then((data)=>{if(!data.Images.length){throw new Error('No images found')}
-          console.log('"describeImages" response:\n',data);physicalId=data.Images[0].ImageId;success()}).catch((err)=>failed(err))}else{var imageId=physicalId;console.log('Searching AMI with ID='+imageId);ec2.describeImages({ImageIds:[imageId]}).promise().then((data)=>{if(!data.Images.length){throw new Error('No images found')}
-          console.log('"describeImages" response:\n',data);return ec2.deregisterImage({ImageId:imageId}).promise()}).then((data)=>{console.log('Image deregistered:\n',data);return ec2.describeSnapshots({Filters:[{Name:'description',Values:['*'+imageId+'*']}]}).promise()}).then((data)=>{console.log('"describeSnapshots" response:\n',data);if(!data.Snapshots.length){throw new Error('No snapshots found')}
-          return ec2.deleteSnapshot({SnapshotId:data.Snapshots[0].SnapshotId}).promise()}).then((data)=>{console.log('Snapshot deleted:\n',data);success()}).catch((err)=>failed(err))}}
-      Runtime: nodejs12.x
-      Timeout: 60
-  DeleteAMILambdaExecutionRole:
-    Type: AWS::IAM::Role
-    Properties:
-      AssumeRolePolicyDocument:
-        Version: '2012-10-17'
-        Statement:
-          - Effect: Allow
-            Principal:
-              Service:
-                - lambda.amazonaws.com
-            Action:
-              - 'sts:AssumeRole'
-      Path: /
-      ManagedPolicyArns:
-        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
-        - arn:aws:iam::aws:policy/service-role/AWSLambdaRole
-      Policies:
-        - PolicyName: EC2Policy
-          PolicyDocument:
-            Version: '2012-10-17'
-            Statement:
-              - Effect: Allow
-                Action:
-                  - 'ec2:DescribeImages'
-                  - 'ec2:DescribeSnapshots'
-                  - 'ec2:DeregisterImage'
-                  - 'ec2:DeleteSnapshot'
-                Resource: ['*']
-  DeleteAMIFunctionRetention:
-    Type: Custom::LogsRetention
-    DependsOn: DeleteAMI
-    Properties:
-      ServiceToken: !GetAtt SetLogsRetentionFunction.Arn
-      LogGroupName: !Join ['', ['/aws/lambda/', !Ref DeleteAMIFunction]]
-      RetentionInDays: 1
-
-  # function to change a log group retention (for lambdas by default they never expire)
-  SetLogsRetentionFunction:
-    Type: AWS::Lambda::Function
-    Properties:
-      Handler: index.handler
-      Role: !GetAtt SetLogsRetentionLambdaExecutionRole.Arn
-      Runtime: nodejs12.x
-      Timeout: 30
-      Code:
-        ZipFile: |
-          var aws=require("aws-sdk");var response=require('cfn-response');exports.handler=function(event,context){console.log("request received:\n"+JSON.stringify(event));var physicalId=event.PhysicalResourceId;function success(data){data=data||{}
-          console.log('SUCCESS:\n',data);return response.send(event,context,response.SUCCESS,data,physicalId)}
-          function failed(err){console.log('FAILED:\n',err);return response.send(event,context,response.FAILED,err,physicalId)}
-          if(event.RequestType!=='Create'){console.log('Non-create request is ignored');return success()}
-          var logGroupName=event.ResourceProperties.LogGroupName;if(!logGroupName){return failed('LogGroupName required')}
-          var retentionInDays=event.ResourceProperties.RetentionInDays;if(!retentionInDays){return failed('RetentionInDays required')}
-          var cloudwatchlogs=new aws.CloudWatchLogs();cloudwatchlogs.putRetentionPolicy({logGroupName:logGroupName,retentionInDays:retentionInDays}).promise().then((data)=>{console.log('"putRetentionPolicy" Response:\n',JSON.stringify(data));success()}).catch((err)=>failed(err))}
-  SetLogsRetentionLambdaExecutionRole:
-    Type: AWS::IAM::Role
-    Properties:
-      AssumeRolePolicyDocument:
-        Version: '2012-10-17'
-        Statement:
-          - Effect: Allow
-            Principal:
-              Service:
-                - lambda.amazonaws.com
-            Action:
-              - 'sts:AssumeRole'
-      Path: /
-      ManagedPolicyArns:
-        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
-        - arn:aws:iam::aws:policy/service-role/AWSLambdaRole
-      Policies:
-        - PolicyName: CloudWatchLogsPolicy
-          PolicyDocument:
-            Version: '2012-10-17'
-            Statement:
-              - Effect: Allow
-                Action:
-                  - logs:PutRetentionPolicy
-                Resource:
-                  - arn:aws:logs:*:*:*
-  SetLogsRetentionFunctionRetention:
-    Type: Custom::LogsRetention
-    Properties:
-      ServiceToken: !GetAtt SetLogsRetentionFunction.Arn
-      LogGroupName: !Join ['', ['/aws/lambda/', !Ref SetLogsRetentionFunction]]
-      RetentionInDays: 1
-
-Outputs:
-  BaseAMI:
-    Description: Base AMI ID
-    Value: !Ref FindBaseAMI
-  NewAMI:
-    Description: Created AMI ID
-    Value: !Ref DeleteAMI
diff --git a/spotty/providers/aws/deployment/cf_templates/data/instance.yaml b/spotty/providers/aws/deployment/cf_templates/data/instance.yaml
deleted file mode 100644
index e8d8192..0000000
--- a/spotty/providers/aws/deployment/cf_templates/data/instance.yaml
+++ /dev/null
@@ -1,345 +0,0 @@
-Description: Spotty EC2 Instance
-Parameters:
-  VpcId:
-    Description: VPC ID
-    Type: AWS::EC2::VPC::Id
-  InstanceProfileArn:
-    Description: Instance Profile ARN
-    Type: String
-  InstanceType:
-    Description: Instance type
-    Type: String
-  KeyName:
-    Description: EC2 Key Pair name
-    Type: AWS::EC2::KeyPair::KeyName
-  ImageId:
-    Description: AMI ID
-    Type: AWS::EC2::Image::Id
-  RootVolumeSize:
-    Description: Root volume size
-    Type: String
-  VolumeMountDirectories:
-    Description: Directories where the volumes should be mounted (bash array)
-    Type: String
-    Default: ''
-  DockerDataRootDirectory:
-    Description: Docker data root directory
-    Type: String
-    Default: ''
-  DockerImage:
-    Description: Docker image to run
-    Type: String
-    Default: ''
-  DockerfilePath:
-    Description: Dockerfile to build and to use instead of the image
-    Type: String
-    Default: ''
-  DockerBuildContextPath:
-    Description: Docker build's context path for the Dockerfile
-    Type: String
-    Default: ''
-  DockerRuntimeParameters:
-    Description: Parameters for the "docker run" command
-    Type: String
-    Default: ''
-  DockerWorkingDirectory:
-    Description: Working directory inside Docker container
-    Type: String
-    Default: ''
-  InstanceNameTag:
-    Description: Name for the instance
-    Type: String
-    Default: ''
-  HostProjectDirectory:
-    Description: Destination directory for the project
-    Type: String
-    Default: ''
-  ProjectS3Path:
-    Description: An S3 path where the project files are located
-    Type: String
-    Default: ''
-  SyncCommandArgs:
-    Description: Arguments for the "aws s3 sync" command to synchronize instance with the S3 bucket
-    Type: String
-    Default: ''
-  UploadS3Path:
-    Description: An S3 path where to upload files for downloads
-    Type: String
-    Default: ''
-Resources:
-  Instance:
-    Type: AWS::EC2::Instance
-    Properties:
-      LaunchTemplate:
-        LaunchTemplateId: !Ref InstanceLaunchTemplate
-        Version: !GetAtt InstanceLaunchTemplate.LatestVersionNumber
-
-  InstanceLaunchTemplate:
-    Type: AWS::EC2::LaunchTemplate
-    Properties:
-      LaunchTemplateData:
-        InstanceType: !Ref InstanceType
-        ImageId: !Ref ImageId
-        KeyName: !Ref KeyName
-        EbsOptimized: 'false'
-        TagSpecifications:
-          - ResourceType: instance
-            Tags:
-              - Key: Name
-                Value: !Ref InstanceNameTag
-        IamInstanceProfile:
-          Arn: !Ref InstanceProfileArn
-        SecurityGroupIds:
-          - !Ref InstanceSecurityGroup
-        InstanceInitiatedShutdownBehavior: terminate
-        InstanceMarketOptions:
-          MarketType: spot
-          SpotOptions:
-            SpotInstanceType: one-time
-            InstanceInterruptionBehavior: terminate
-        BlockDeviceMappings:
-          - DeviceName: /dev/sda1
-            Ebs:
-              VolumeSize: !Ref RootVolumeSize
-              DeleteOnTermination: true
-        UserData:
-          'Fn::Base64': !Sub |
-            #!/bin/bash -x
-
-            cd /root
-
-            # install CloudFormation tools if they are not installed yet
-            if [ ! -e /usr/local/bin/cfn-init ]; then
-              apt-get update
-              apt-get install -y python-setuptools
-              mkdir -p aws-cfn-bootstrap-latest
-              curl https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.tar.gz | tar xz -C aws-cfn-bootstrap-latest --strip-components 1
-              python2 -m easy_install aws-cfn-bootstrap-latest
-            fi
-
-            # prepare the instance and run Docker container
-            /usr/local/bin/cfn-init \
-              --stack ${AWS::StackName} \
-              --resource InstanceLaunchTemplate \
-              --region ${AWS::Region} \
-              -c init \
-              -v
-
-            # send signal that the Docker container is ready or failed
-            /usr/local/bin/cfn-signal \
-              -e $? \
-              --stack ${AWS::StackName} \
-              --region ${AWS::Region} \
-              --resource DockerReadyWaitCondition
-    Metadata:
-      'AWS::CloudFormation::Init':
-        configSets:
-          init:
-            - prepare_instance_config
-            - mount_volumes_config
-            - sync_project_config
-            - startup_commands_config
-            - docker_container_config
-        prepare_instance_config:
-          files:
-            /tmp/spotty/instance/scripts/prepare_instance.sh:
-              owner: ubuntu
-              group: ubuntu
-              mode: '000755'
-              content: !Sub |
-                #!/bin/bash -e
-
-                # install AWS CLI
-                update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
-                apt-get update && apt-get install -y python3-pip
-                pip3 install -U awscli
-                aws configure set default.region ${AWS::Region}
-
-                # install jq
-                apt-get install -y jq
-
-                # create directory for Docker scripts
-                mkdir -p /tmp/spotty/container/scripts/run
-                chmod -R 755 /tmp/spotty
-                chown -R ubuntu:ubuntu /tmp/spotty
-
-                # create directory for Spotty logs
-                mkdir -p /var/log/spotty/run
-                chmod -R 755 /var/log/spotty
-                chown -R ubuntu:ubuntu /var/log/spotty
-
-                # create a project directory
-                if [ -n "${HostProjectDirectory}" ]; then
-                  mkdir -p ${HostProjectDirectory}
-                fi
-            /tmp/spotty/instance/scripts/upload_files.sh:
-              owner: ubuntu
-              group: ubuntu
-              mode: '000755'
-              content: !Sub |
-                #!/bin/bash -e
-
-                aws s3 sync ${HostProjectDirectory} ${UploadS3Path} "$@"
-            /home/ubuntu/.tmux.conf:
-              owner: ubuntu
-              group: ubuntu
-              mode: '000644'
-              content: !Sub |
-                bind-key x kill-pane
-          commands:
-            prepare_instance:
-              command: '/bin/bash -xe /tmp/spotty/instance/scripts/prepare_instance.sh'
-        mount_volumes_config:
-          files:
-            /tmp/spotty/instance/scripts/mount_volumes.sh:
-              owner: ubuntu
-              group: ubuntu
-              mode: '000755'
-              content: !Sub |
-                #!/bin/bash -e
-
-                DEVICE_LETTERS=(f g h i j k l m n o p)
-                MOUNT_DIRS=(${VolumeMountDirectories})
-
-                for i in ${!!MOUNT_DIRS[*]}
-                do
-                  DEVICE=/dev/xvd${!DEVICE_LETTERS[$i]}
-                  MOUNT_DIR=${!MOUNT_DIRS[$i]}
-
-                  blkid -o value -s TYPE $DEVICE || mkfs -t ext4 $DEVICE
-                  mkdir -p $MOUNT_DIR
-                  mount $DEVICE $MOUNT_DIR
-                  resize2fs $DEVICE
-                done
-          commands:
-            mount_volumes:
-              command: '/bin/bash -xe /tmp/spotty/instance/scripts/mount_volumes.sh'
-        sync_project_config:
-          files:
-            /tmp/spotty/instance/scripts/sync_project.sh:
-              owner: ubuntu
-              group: ubuntu
-              mode: '000755'
-              content: !Sub |
-                #!/bin/bash -e
-
-                aws s3 sync ${ProjectS3Path} ${HostProjectDirectory} "$@"
-          commands:
-            sync_project:
-              command: !Sub '/bin/bash -xe /tmp/spotty/instance/scripts/sync_project.sh ${SyncCommandArgs}'
-        startup_commands_config:
-          files:
-            /tmp/spotty/instance/scripts/startup_commands.sh:
-              owner: ubuntu
-              group: ubuntu
-              mode: '000644'
-              content: '# no content'
-          commands:
-            startup_commands:
-              command: !Sub '/bin/bash -xe /tmp/spotty/instance/scripts/startup_commands.sh'
-        docker_container_config:
-          files:
-            /tmp/spotty/instance/scripts/run_container.sh:
-              owner: ubuntu
-              group: ubuntu
-              mode: '000755'
-              content: !Sub |
-                #!/bin/bash -e
-
-                # change docker data root directory
-                if [ -n "${DockerDataRootDirectory}" ]; then
-                  jq '. + { "data-root": "${DockerDataRootDirectory}" }' /etc/docker/daemon.json > /tmp/docker_daemon.json \
-                    && mv /tmp/docker_daemon.json /etc/docker/daemon.json
-                  service docker restart
-
-                  # remove all containers
-                  if [[ $(docker ps -aq | wc -c) -ne 0 ]]; then
-                    docker rm $(docker ps -aq)
-                  fi
-                fi
-
-                # build docker image
-                DOCKER_IMAGE="${DockerImage}"
-                if [ -n "${DockerfilePath}" ]; then
-                  DOCKER_IMAGE=spotty:`date +%s`
-                  docker build -t $DOCKER_IMAGE -f ${DockerfilePath} ${DockerBuildContextPath}
-                fi
-
-                if [ -n "$DOCKER_IMAGE" ]; then
-                  # run docker container
-                  CONTAINER_NAME=spotty
-                  docker run ${DockerRuntimeParameters} \
-                    -v /root/.aws:/root/.aws \
-                    -v /tmp/spotty/container/scripts:/tmp/scripts \
-                    --name $CONTAINER_NAME $DOCKER_IMAGE /bin/sh
-
-                  WORKING_DIR=""
-                  if [ -n "${DockerWorkingDirectory}" ]; then
-                    WORKING_DIR="-w ${DockerWorkingDirectory}"
-
-                    # create working directory if it doesn't exist
-                    docker exec $CONTAINER_NAME mkdir -p ${DockerWorkingDirectory}
-                  fi
-
-                  # create a script to connect to the docker container
-                  cat > /tmp/spotty/instance/scripts/container_bash.sh <<EOL
-                #!/bin/bash
-                docker exec -it $WORKING_DIR $CONTAINER_NAME /bin/bash "\$@"
-                EOL
-                  chmod 755 /tmp/spotty/instance/scripts/container_bash.sh
-                  chown ubuntu:ubuntu /tmp/spotty/instance/scripts/container_bash.sh
-
-                  # create an alias to connect to the docker container
-                  CONTAINER_BASH_ALIAS=container
-                  echo "alias $CONTAINER_BASH_ALIAS=\"sudo /tmp/spotty/instance/scripts/container_bash.sh\"" >> /home/ubuntu/.bashrc
-                  echo "alias $CONTAINER_BASH_ALIAS=\"/tmp/spotty/instance/scripts/container_bash.sh\"" >> /root/.bashrc
-
-                  # run custom user commands
-                  docker exec $WORKING_DIR $CONTAINER_NAME /bin/bash -xe /tmp/scripts/startup_commands.sh \
-                    > /var/log/spotty/container-startup-commands.log 2>&1
-                fi
-            /tmp/spotty/container/scripts/startup_commands.sh:
-              owner: ubuntu
-              group: ubuntu
-              mode: '000644'
-              content: '# no content'
-          commands:
-            run_container:
-              command: '/bin/bash -xe /tmp/spotty/instance/scripts/run_container.sh'
-
-  InstanceSecurityGroup:
-    Type: AWS::EC2::SecurityGroup
-    Properties:
-      VpcId: !Ref VpcId
-      GroupDescription: Spotty security group
-      SecurityGroupEgress:
-        - CidrIp: 0.0.0.0/0
-          IpProtocol: -1
-          FromPort: 0
-          ToPort: 65535
-        - CidrIpv6: ::/0
-          IpProtocol: -1
-          FromPort: 0
-          ToPort: 65535
-      SecurityGroupIngress:
-        - CidrIp: 0.0.0.0/0
-          IpProtocol: tcp
-          FromPort: 22
-          ToPort: 22
-        - CidrIpv6: ::/0
-          IpProtocol: tcp
-          FromPort: 22
-          ToPort: 22
-
-  DockerReadyWaitCondition:
-    Type: AWS::CloudFormation::WaitCondition
-    DependsOn: Instance
-    CreationPolicy:
-      ResourceSignal:
-        Timeout: PT30M
-
-Outputs:
-  InstanceId:
-    Value: !Ref Instance
-  AvailabilityZone:
-    Value: !GetAtt Instance.AvailabilityZone
diff --git a/spotty/providers/aws/deployment/cf_templates/data/lambdas/create_ami/delete_ami.js b/spotty/providers/aws/deployment/cf_templates/data/lambdas/create_ami/delete_ami.js
deleted file mode 100644
index 95691da..0000000
--- a/spotty/providers/aws/deployment/cf_templates/data/lambdas/create_ami/delete_ami.js
+++ /dev/null
@@ -1,78 +0,0 @@
-var aws = require('aws-sdk');
-var response = require('cfn-response');
-
-exports.handler = function(event, context) {
-    console.log('Request received:\n', JSON.stringify(event));
-
-    var physicalId = event.PhysicalResourceId;
-
-    function success(data) {
-        data = data || {};
-        console.log('SUCCESS:\n', data);
-        return response.send(event, context, response.SUCCESS, data, physicalId);
-    }
-
-    function failed(err) {
-        console.log('FAILED:\n', err);
-        return response.send(event, context, response.FAILED, err, physicalId);
-    }
-
-    var ec2 = new aws.EC2({region: event.ResourceProperties.Region});
-
-    if (event.RequestType == 'Create' || event.RequestType == 'Update') {
-        ec2.describeImages({Filters: [{Name: 'tag:spotty:stack-id',  Values: [event.StackId]}]})
-        .promise()
-        .then((data) => {
-            if (!data.Images.length) {
-                throw new Error('No images found');
-            }
-
-            console.log('"describeImages" response:\n', data);
-
-            // set physical resource ID to AMI ID
-            physicalId = data.Images[0].ImageId;
-            success();
-        })
-        .catch((err) => failed(err));
-    } else {
-        var imageId = physicalId;
-
-        console.log('Searching AMI with ID=' + imageId);
-
-        ec2.describeImages({ImageIds: [imageId]})
-        .promise()
-        .then((data) => {
-            if (!data.Images.length) {
-                throw new Error('No images found');
-            }
-
-            console.log('"describeImages" response:\n', data);
-
-            return ec2.deregisterImage({ImageId: imageId}).promise();
-        })
-        .then((data) => {
-            console.log('Image deregistered:\n', data);
-
-            return ec2.describeSnapshots({
-                Filters: [{
-                    Name: 'description',
-                    Values: ['*' + imageId + '*']
-                }]
-            }).promise();
-        })
-        .then((data) => {
-            console.log('"describeSnapshots" response:\n', data);
-
-            if (!data.Snapshots.length) {
-                throw new Error('No snapshots found');
-            }
-
-            return ec2.deleteSnapshot({SnapshotId: data.Snapshots[0].SnapshotId}).promise();
-        })
-        .then((data) => {
-            console.log('Snapshot deleted:\n', data);
-            success();
-        })
-        .catch((err) => failed(err));
-    }
-};
diff --git a/spotty/providers/aws/deployment/cf_templates/data/lambdas/create_ami/search_ami.js b/spotty/providers/aws/deployment/cf_templates/data/lambdas/create_ami/search_ami.js
deleted file mode 100644
index 1f68f5a..0000000
--- a/spotty/providers/aws/deployment/cf_templates/data/lambdas/create_ami/search_ami.js
+++ /dev/null
@@ -1,64 +0,0 @@
-var aws = require("aws-sdk");
-var response = require('cfn-response');
-
-exports.handler = function(event, context) {
-    console.log("request received:\n" + JSON.stringify(event));
-
-    var physicalId = event.PhysicalResourceId;
-
-    function success(data) {
-        data = data || {};
-        console.log('SUCCESS:\n', data);
-        return response.send(event, context, response.SUCCESS, data, physicalId);
-    }
-
-    function failed(err) {
-        console.log('FAILED:\n', err);
-        return response.send(event, context, response.FAILED, err, physicalId);
-    }
-
-    // ignore delete request
-    if (event.RequestType == 'Delete') {
-        console.log('Delete request is ignored');
-        return success();
-    }
-
-    var ec2 = new aws.EC2({region: event.ResourceProperties.Region});
-
-    // get AMI IDs with the specified name pattern and owner
-    ec2.describeImages({
-        Filters: [{ Name: "name", Values: ['ubuntu/images/hvm-ssd/ubuntu-xenial-16.04-amd64*']}],
-        Owners: ['099720109477']
-    })
-    .promise()
-    .then((data) => {
-        console.log('Number of found images: ', data.Images.length);
-
-        var images = data.Images;
-
-        // sort images by name in descending order (the names contain the AMI version, formatted as YYYYMMDD.Ver)
-        images.sort(function(x, y) { return y.Name.localeCompare(x.Name); });
-
-        var amiId = false;
-        for (var j = 0; j < images.length; j++) {
-            if (isBeta(images[j].Name)) continue;
-            amiId = images[j].ImageId;
-            break;
-        }
-
-        if (!amiId) {
-            throw new Error('AMI not found');
-        }
-
-        console.log('Found AMI ID=' + amiId)
-
-        physicalId = amiId;
-        success();
-    })
-    .catch((err) => failed(err));
-};
-
-// Check if the image is a beta or rc image. The Lambda function won't return any of those images.
-function isBeta(imageName) {
-    return imageName.toLowerCase().indexOf("beta") > -1 || imageName.toLowerCase().indexOf(".rc") > -1;
-}
diff --git a/spotty/providers/aws/deployment/cf_templates/data/lambdas/set_logs_retention.js b/spotty/providers/aws/deployment/cf_templates/data/lambdas/set_logs_retention.js
deleted file mode 100644
index da17977..0000000
--- a/spotty/providers/aws/deployment/cf_templates/data/lambdas/set_logs_retention.js
+++ /dev/null
@@ -1,48 +0,0 @@
-var aws = require("aws-sdk");
-var response = require('cfn-response');
-
-exports.handler = function(event, context) {
-    console.log("request received:\n" + JSON.stringify(event));
-
-    var physicalId = event.PhysicalResourceId;
-
-    function success(data) {
-        data = data || {}
-        console.log('SUCCESS:\n', data);
-        return response.send(event, context, response.SUCCESS, data, physicalId);
-    }
-
-    function failed(err) {
-        console.log('FAILED:\n', err);
-        return response.send(event, context, response.FAILED, err, physicalId);
-    }
-
-    // ignore non-create requests
-    if (event.RequestType !== 'Create') {
-        console.log('Non-create request is ignored');
-        return success();
-    }
-
-    var logGroupName = event.ResourceProperties.LogGroupName;
-    if (!logGroupName) {
-        return failed('LogGroupName required');
-    }
-
-    var retentionInDays = event.ResourceProperties.RetentionInDays;
-    if (!retentionInDays) {
-        return failed('RetentionInDays required');
-    }
-
-    var cloudwatchlogs = new aws.CloudWatchLogs();
-
-    cloudwatchlogs.putRetentionPolicy({
-        logGroupName: logGroupName,
-        retentionInDays: retentionInDays
-    })
-    .promise()
-    .then((data) => {
-        console.log('"putRetentionPolicy" Response:\n', JSON.stringify(data));
-        success();
-    })
-    .catch((err) => failed(err));
-};
diff --git a/spotty/providers/aws/deployment/cf_templates/instance_template.py b/spotty/providers/aws/deployment/cf_templates/instance_template.py
deleted file mode 100644
index 8d02451..0000000
--- a/spotty/providers/aws/deployment/cf_templates/instance_template.py
+++ /dev/null
@@ -1,192 +0,0 @@
-from typing import List
-import os
-import yaml
-from cfn_tools import CfnYamlLoader, CfnYamlDumper
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.deployment.abstract_instance_volume import AbstractInstanceVolume
-from spotty.deployment.container_deployment import ContainerDeployment
-from spotty.providers.aws.config.instance_config import InstanceConfig
-from spotty.providers.aws.deployment.project_resources.ebs_volume import EbsVolume
-
-
-def prepare_instance_template(instance_config: InstanceConfig, volumes: List[AbstractInstanceVolume],
-                              availability_zone: str, container: ContainerDeployment, output: AbstractOutputWriter):
-    """Prepares CloudFormation template to run a Spot Instance."""
-
-    # read and update CF template
-    with open(os.path.join(os.path.dirname(__file__), 'data', 'instance.yaml')) as f:
-        template = yaml.load(f, Loader=CfnYamlLoader)
-
-    # get volume resources and updated availability zone
-    volume_resources = _get_volume_resources(volumes, output)
-
-    # add volume resources to the template
-    template['Resources'].update(volume_resources)
-
-    # set availability zone
-    if availability_zone:
-        template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['Placement'] = {
-            'AvailabilityZone': availability_zone,
-        }
-        output.write('- availability zone: %s' % availability_zone)
-    else:
-        output.write('- availability zone: auto')
-
-    # set subnet
-    if instance_config.subnet_id:
-        template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['NetworkInterfaces'] = [
-            {
-                'SubnetId': instance_config.subnet_id,
-                'DeviceIndex': 0,
-                'Groups': template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData'][
-                    'SecurityGroupIds'],
-            }]
-        del template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData']['SecurityGroupIds']
-
-    # add ports to the security group
-    for port in container.config.ports:
-        if port != 22:
-            template['Resources']['InstanceSecurityGroup']['Properties']['SecurityGroupIngress'] += [{
-                'CidrIp': '0.0.0.0/0',
-                'IpProtocol': 'tcp',
-                'FromPort': port,
-                'ToPort': port,
-            }, {
-                'CidrIpv6': '::/0',
-                'IpProtocol': 'tcp',
-                'FromPort': port,
-                'ToPort': port,
-            }]
-
-    if instance_config.on_demand:
-        # run on-demand instance
-        del template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData'][
-            'InstanceMarketOptions']
-        output.write('- on-demand instance')
-    else:
-        # set maximum price
-        if instance_config.max_price:
-            template['Resources']['InstanceLaunchTemplate']['Properties']['LaunchTemplateData'] \
-                ['InstanceMarketOptions']['SpotOptions']['MaxPrice'] = instance_config.max_price
-
-        output.write('- maximum Spot Instance price: %s'
-                     % (('%.04f' % instance_config.max_price) if instance_config.max_price else 'on-demand'))
-
-    # set initial instance commands
-    if instance_config.commands:
-        template['Resources']['InstanceLaunchTemplate']['Metadata']['AWS::CloudFormation::Init'] \
-            ['startup_commands_config']['files']['/tmp/spotty/instance/scripts/startup_commands.sh']['content'] \
-            = instance_config.commands
-
-    # set initial docker commands
-    if container.config.commands:
-        template['Resources']['InstanceLaunchTemplate']['Metadata']['AWS::CloudFormation::Init'] \
-            ['docker_container_config']['files']['/tmp/spotty/container/scripts/startup_commands.sh']['content'] \
-            = container.config.commands
-
-    return yaml.dump(template, Dumper=CfnYamlDumper)
-
-
-def _get_volume_attachment_resource(volume_id, device_name):
-    attachment_resource = {
-        'Type': 'AWS::EC2::VolumeAttachment',
-        'Properties': {
-            'Device': device_name,
-            'InstanceId': {'Ref': 'Instance'},
-            'VolumeId': volume_id,
-        },
-    }
-
-    return attachment_resource
-
-
-def _get_volume_resource(volume: EbsVolume, output: AbstractOutputWriter):
-    # new volume will be created
-    volume_resource = {
-        'Type': 'AWS::EC2::Volume',
-        'DeletionPolicy': 'Retain',
-        'Properties': {
-            'AvailabilityZone': {'Fn::GetAtt': ['Instance', 'AvailabilityZone']},
-            'Tags': [{
-                'Key': 'Name',
-                'Value': volume.ec2_volume_name,
-            }],
-            'VolumeType': volume.type,
-        },
-    }
-
-    # check if the snapshot exists and restore the volume from it
-    snapshot = volume.get_snapshot()
-    if snapshot:
-        # volume will be restored from the snapshot
-        # check size of the volume
-        if volume.size and (volume.size < snapshot.size):
-            raise ValueError('Specified size for the "%s" volume (%dGB) is less than size of the '
-                             'snapshot (%dGB).'
-                             % (volume.name, volume.size, snapshot.size))
-
-        # set snapshot ID
-        volume_resource['Properties']['SnapshotId'] = snapshot.snapshot_id
-
-        output.write('- volume "%s" will be restored from the snapshot' % volume.ec2_volume_name)
-
-    else:
-        # empty volume will be created, check that the size is specified
-        if not volume.size:
-            raise ValueError('Size for the new volume is required.')
-
-        output.write('- volume "%s" will be created' % volume.ec2_volume_name)
-
-    # set size of the volume
-    if volume.size:
-        volume_resource['Properties']['Size'] = volume.size
-
-    # set a name for the new volume
-    volume_resource['Properties']['Tags'] = [{'Key': 'Name', 'Value': volume.ec2_volume_name}]
-
-    return volume_resource
-
-
-def _get_volume_resources(volumes: List[AbstractInstanceVolume], output: AbstractOutputWriter):
-    resources = {}
-
-    # ending letters for the devices (https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/device_naming.html)
-    # TODO: different device names on Nitro-based instances,
-    # see: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/nvme-ebs-volumes.html
-    device_letters = 'fghijklmnop'
-
-    # create and attach volumes
-    for i, volume in enumerate(volumes):
-        if isinstance(volume, EbsVolume):
-            device_letter = device_letters[i]
-
-            ec2_volume = volume.get_ec2_volume()
-            if ec2_volume:
-                # check if the volume is available
-                if not ec2_volume.is_available():
-                    raise ValueError('EBS volume "%s" is not available (state: %s).'
-                                     % (volume.ec2_volume_name, ec2_volume.state))
-
-                # check size of the volume
-                if volume.size and (volume.size != ec2_volume.size):
-                    raise ValueError('Specified size for the "%s" volume (%dGB) doesn\'t match the size of the '
-                                     'existing volume (%dGB).' % (volume.name, volume.size, ec2_volume.size))
-
-                output.write('- volume "%s" (%s) will be attached' % (ec2_volume.name, ec2_volume.volume_id))
-
-                volume_id = ec2_volume.volume_id
-            else:
-                # create Volume resource
-                vol_resource_name = 'Volume' + device_letter.upper()
-                vol_resource = _get_volume_resource(volume, output)
-                resources[vol_resource_name] = vol_resource
-
-                volume_id = {'Ref': vol_resource_name}
-
-            # create VolumeAttachment resource
-            vol_attachment_resource_name = 'VolumeAttachment' + device_letter.upper()
-            device_name = '/dev/sd' + device_letter
-            vol_attachment_resource = _get_volume_attachment_resource(volume_id, device_name)
-            resources[vol_attachment_resource_name] = vol_attachment_resource
-
-    return resources
diff --git a/spotty/providers/aws/deployment/instance_deployment.py b/spotty/providers/aws/deployment/instance_deployment.py
deleted file mode 100644
index e69a08a..0000000
--- a/spotty/providers/aws/deployment/instance_deployment.py
+++ /dev/null
@@ -1,347 +0,0 @@
-from subprocess import list2cmdline
-from typing import List
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.config.project_config import ProjectConfig
-from spotty.config.validation import is_subdir
-from spotty.deployment.abstract_instance_volume import AbstractInstanceVolume
-from spotty.providers.aws.aws_resources.image import Image
-from spotty.helpers.print_info import render_volumes_info_table
-from spotty.providers.aws.aws_resources.snapshot import Snapshot
-from spotty.providers.aws.aws_resources.volume import Volume
-from spotty.providers.aws.config.instance_config import VOLUME_TYPE_EBS
-from spotty.deployment.container_deployment import ContainerDeployment
-from spotty.providers.aws.deployment.abstract_aws_deployment import AbstractAwsDeployment
-from spotty.providers.aws.deployment.checks import check_az_and_subnet, check_max_price
-from spotty.providers.aws.deployment.project_resources.ebs_volume import EbsVolume
-from spotty.providers.aws.deployment.cf_templates.instance_template import prepare_instance_template
-from spotty.providers.aws.deployment.project_resources.instance_profile_stack import InstanceProfileStackResource
-from spotty.providers.aws.helpers.download import get_tmp_instance_s3_path
-from spotty.providers.aws.helpers.sync import sync_project_with_s3, get_project_s3_path, get_instance_sync_arguments
-from spotty.providers.aws.deployment.project_resources.bucket import BucketResource
-from spotty.providers.aws.deployment.project_resources.instance_stack import InstanceStackResource
-from spotty.providers.aws.config.validation import is_nitro_instance, is_gpu_instance
-
-
-class InstanceDeployment(AbstractAwsDeployment):
-
-    @property
-    def ec2_instance_name(self) -> str:
-        return '%s-%s' % (self._project_name.lower(), self.instance_config.name.lower())
-
-    @property
-    def bucket(self) -> BucketResource:
-        return BucketResource(self._project_name, self.instance_config.region)
-
-    @property
-    def stack(self) -> InstanceStackResource:
-        return InstanceStackResource(self._project_name, self.instance_config.name, self.instance_config.region)
-
-    def get_instance(self):
-        return self.stack.get_instance()
-
-    def deploy(self, project_config: ProjectConfig, output: AbstractOutputWriter, dry_run=False):
-        # check that it's not a Nitro-based instance
-        if is_nitro_instance(self.instance_config.instance_type):
-            raise ValueError('Currently Nitro-based instances are not supported.')
-
-        # check availability zone and subnet configuration
-        check_az_and_subnet(self._ec2, self.instance_config.region, self.instance_config.availability_zone,
-                            self.instance_config.subnet_id)
-
-        # get volumes
-        volumes = self._get_volumes()
-
-        # get deployment availability zone
-        availability_zone = self._get_availability_zone(volumes)
-
-        # check the maximum price for a spot instance
-        check_max_price(self._ec2, self.instance_config.instance_type, self.instance_config.on_demand,
-                        self.instance_config.max_price, availability_zone)
-
-        # create or get existing bucket for the project
-        bucket_name = self.bucket.get_or_create_bucket(output, dry_run)
-
-        # sync the project with the bucket
-        output.write('Syncing the project with S3 bucket...')
-        sync_project_with_s3(project_config.project_dir, bucket_name, self.instance_config.region,
-                             project_config.sync_filters, dry_run)
-
-        # create or update instance profile
-        if not dry_run:
-            instance_profile_stack = InstanceProfileStackResource(
-                self._project_name, self.instance_config.name, self.instance_config.region)
-            instance_profile_arn = instance_profile_stack.create_or_update_stack(
-                self.instance_config.managed_policy_arns, output=output)
-        else:
-            instance_profile_arn = None
-
-        output.write('Preparing CloudFormation template...')
-
-        # prepare CloudFormation template
-        container = ContainerDeployment(project_config.project_name, volumes, project_config.container)
-        with output.prefix('  '):
-            template = prepare_instance_template(self.instance_config, volumes, availability_zone, container,
-                                                 output)
-
-            # get parameters for the template
-            parameters = self._get_template_parameters(instance_profile_arn, self.instance_config.name, bucket_name,
-                                                       project_config.sync_filters, volumes, container, output,
-                                                       dry_run=dry_run)
-
-        # print information about the volumes
-        output.write('\nVolumes:\n%s\n' % render_volumes_info_table(container.volume_mounts, volumes))
-
-        # create stack
-        if not dry_run:
-            self.stack.create_or_update_stack(template, parameters, output)
-
-    def delete(self, output: AbstractOutputWriter):
-        # terminate the instance
-        instance = self.get_instance()
-        if instance:
-            output.write('Terminating the instance...')
-            instance.terminate()
-            instance.wait_instance_terminated()
-        else:
-            output.write('The instance is already terminated.')
-
-        # delete the stack in background if it exists
-        self.stack.delete_stack(output, no_wait=True)
-
-        output.write('Applying deletion policies for the volumes...')
-
-        # apply deletion policies for the volumes
-        with output.prefix('  '):
-            self._apply_deletion_policies(output)
-
-    def _get_availability_zone(self, volumes: List[AbstractInstanceVolume]):
-        """Checks that existing volumes located in the same AZ and the AZ from the
-        config file matches volumes AZ.
-
-        Args:
-            volumes: List of volume objects.
-
-        Returns:
-            The final AZ where the instance should be run or an empty string if
-            the instance can be run in any AZ.
-
-        Raises:
-            ValueError: AZ in the config file doesn't match the AZs of the volumes or
-                AZs of the volumes are different.
-        """
-        availability_zone = self.instance_config.availability_zone
-        for volume in volumes:
-            if isinstance(volume, EbsVolume):
-                ec2_volume = volume.get_ec2_volume()
-                if ec2_volume:
-                    if availability_zone and (availability_zone != ec2_volume.availability_zone):
-                        raise ValueError(
-                            'The availability zone in the configuration file doesn\'t match the availability zone '
-                            'of the existing volume or you have two existing volumes in different availability '
-                            'zones.')
-
-                    # update availability zone
-                    availability_zone = ec2_volume.availability_zone
-
-        return availability_zone
-
-    def _get_volumes(self) -> List[AbstractInstanceVolume]:
-        volumes = []
-        for volume_config in self.instance_config.volumes:
-            volume_type = volume_config['type']
-            if volume_type == VOLUME_TYPE_EBS:
-                volumes.append(EbsVolume(self._ec2, volume_config, self._project_name, self.instance_config.name))
-            else:
-                raise ValueError('AWS volume type "%s" not supported.' % volume_type)
-
-        return volumes
-
-    def _get_template_parameters(self, instance_profile_arn: str, instance_name: str, bucket_name: str,
-                                 sync_filters: list, volumes: List[AbstractInstanceVolume],
-                                 container: ContainerDeployment, output: AbstractOutputWriter, dry_run=False):
-        # get VPC ID
-        vpc_id = self.get_vpc_id()
-
-        # get image info
-        ami = self._get_ami()
-        output.write('- AMI: "%s" (%s)' % (ami.name, ami.image_id))
-
-        # check root volume size
-        root_volume_size = self.instance_config.root_volume_size
-        if root_volume_size and root_volume_size < ami.size:
-            raise ValueError('Root volume size cannot be less than the size of AMI (%dGB).' % ami.size)
-        elif not root_volume_size:
-            # if a root volume size is not specified, make it 5GB larger than the AMI size
-            root_volume_size = ami.size + 5
-
-        # create key pair
-        key_name = self.key_pair.get_or_create_key(dry_run)
-
-        # get mount directories for the volumes
-        mount_dirs = [volume.mount_dir for volume in volumes]
-
-        # get Docker runtime parameters
-        runtime_parameters = container.get_runtime_parameters(is_gpu_instance(self.instance_config.instance_type))
-
-        # print info about the Docker data root
-        if self.instance_config.docker_data_root:
-            docker_data_volume_name = [volume.name for volume in volumes
-                                       if is_subdir(self.instance_config.docker_data_root, volume.mount_dir)][0]
-            output.write('- Docker data will be stored on the "%s" volume' % docker_data_volume_name)
-
-        # create stack
-        parameters = {
-            'VpcId': vpc_id,
-            'InstanceProfileArn': instance_profile_arn,
-            'InstanceType': self.instance_config.instance_type,
-            'KeyName': key_name,
-            'ImageId': ami.image_id,
-            'RootVolumeSize': str(root_volume_size),
-            'VolumeMountDirectories': ('"%s"' % '" "'.join(mount_dirs)) if mount_dirs else '',
-            'DockerDataRootDirectory': self.instance_config.docker_data_root,
-            'DockerImage': container.config.image,
-            'DockerfilePath': container.dockerfile_path,
-            'DockerBuildContextPath': container.docker_context_path,
-            'DockerRuntimeParameters': runtime_parameters,
-            'DockerWorkingDirectory': container.config.working_dir,
-            'InstanceNameTag': self.ec2_instance_name,
-            'ProjectS3Path': get_project_s3_path(bucket_name),
-            'HostProjectDirectory': container.host_project_dir,
-            'SyncCommandArgs': list2cmdline(get_instance_sync_arguments(sync_filters)),
-            'UploadS3Path': get_tmp_instance_s3_path(bucket_name, instance_name),
-        }
-
-        return parameters
-
-    def _get_ami(self) -> Image:
-        """Returns an AMI that should be used for deployment.
-
-        Raises:
-            ValueError: If an AMI not found.
-        """
-        if self.instance_config.ami_id:
-            # get an AMI by ID if the "amiId" parameter is specified
-            image = Image.get_by_id(self._ec2, self.instance_config.ami_id)
-            if not image:
-                raise ValueError('AMI with ID=%s not found.' % self.instance_config.ami_id)
-        else:
-            # try to get an AMI by its name (if the "amiName" parameter is not specified, the default value is used)
-            image = Image.get_by_name(self._ec2, self.instance_config.ami_name)
-            if not image:
-                if self.instance_config.has_ami_name:
-                    # if an AMI name was explicitly specified in the config, but the AMI was not found, raise an error
-                    raise ValueError('AMI with the name "%s" was not found.' % self.instance_config.ami_name)
-                else:
-                    # get the latest "Deep Learning Base AMI"
-                    res = self._ec2.describe_images(
-                        Owners=['amazon'],
-                        Filters=[{'Name': 'name', 'Values': ['Deep Learning AMI (Ubuntu 16.04) Version*']}],
-                    )
-
-                    if not len(res['Images']):
-                        raise ValueError('AWS Deep Learning AMI not found.\n'
-                                         'Use the "spotty aws create-ami" command to create an AMI with NVIDIA Docker.')
-
-                    image_info = sorted(res['Images'], key=lambda x: x['CreationDate'], reverse=True)[0]
-                    image = Image(self._ec2, image_info)
-
-        return image
-
-    def _apply_deletion_policies(self, output: AbstractOutputWriter):
-        """Applies deletion policies to the EBS volumes."""
-
-        # get volumes
-        volumes = self._get_volumes()
-        ebs_volumes = [volume for volume in volumes if isinstance(volume, EbsVolume)]
-
-        # no volumes
-        if not ebs_volumes:
-            output.write('- no EBS volumes configured')
-            return
-
-        # apply deletion policies
-        wait_snapshots = []
-        for volume in ebs_volumes:
-            # get EC2 volume
-            try:
-                ec2_volume = volume.get_ec2_volume()
-            except Exception as e:
-                output.write('- volume "%s" not found. Error: %s' % (volume.ec2_volume_name, str(e)))
-                continue
-
-            if not ec2_volume:
-                output.write('- volume "%s" not found' % volume.ec2_volume_name)
-                continue
-
-            if not ec2_volume.is_available():
-                output.write('- volume "%s" is not available (state: %s)'
-                             % (volume.ec2_volume_name, ec2_volume.state))
-                continue
-
-            # apply deletion policies
-            if volume.deletion_policy == EbsVolume.DP_RETAIN:
-                # do nothing
-                output.write('- volume "%s" is retained' % ec2_volume.name)
-
-            elif volume.deletion_policy == EbsVolume.DP_DELETE:
-                # delete EBS volume
-                self._delete_ec2_volume(ec2_volume, output)
-
-            elif volume.deletion_policy == EbsVolume.DP_CREATE_SNAPSHOT \
-                    or volume.deletion_policy == EbsVolume.DP_UPDATE_SNAPSHOT:
-                try:
-                    # rename a previous snapshot
-                    prev_snapshot = volume.get_snapshot()
-                    if prev_snapshot:
-                        prev_snapshot.rename('%s-%d' % (prev_snapshot.name, prev_snapshot.creation_time))
-
-                    output.write('- creating a snapshot for the volume "%s"...' % ec2_volume.name)
-
-                    # create a new snapshot
-                    new_snapshot = ec2_volume.create_snapshot()
-
-                    # delete the EBS volume and a previous snapshot only after a new snapshot will be created
-                    wait_snapshots.append({
-                        'new_snapshot': new_snapshot,
-                        'prev_snapshot': prev_snapshot,
-                        'ec2_volume': ec2_volume,
-                        'deletion_policy': volume.deletion_policy,
-                    })
-                except Exception as e:
-                    output.write('- snapshot for the volume "%s" was not created. Error: %s'
-                                 % (volume.ec2_volume_name, str(e)))
-
-            else:
-                raise ValueError('Unsupported deletion policy: "%s".' % volume.deletion_policy)
-
-        # wait until all snapshots will be created
-        for resources in wait_snapshots:
-            try:
-                resources['new_snapshot'].wait_snapshot_completed()
-                output.write('- snapshot for the volume "%s" was created' % resources['new_snapshot'].name)
-            except Exception as e:
-                output.write('- snapshot "%s" was not created. Error: %s' % (resources['new_snapshot'].name, str(e)))
-                continue
-
-            # delete a previous snapshot if it's the "update_snapshot" deletion policy
-            if (resources['deletion_policy'] == EbsVolume.DP_UPDATE_SNAPSHOT) and resources['prev_snapshot']:
-                self._delete_snapshot(resources['prev_snapshot'], output)
-
-            # delete the EBS volume
-            self._delete_ec2_volume(resources['ec2_volume'], output)
-
-    @staticmethod
-    def _delete_ec2_volume(ec2_volume: Volume, output: AbstractOutputWriter):
-        try:
-            ec2_volume.delete()
-            output.write('- volume "%s" was deleted' % ec2_volume.name)
-        except Exception as e:
-            output.write('- volume "%s" was not deleted. Error: %s' % (ec2_volume.name, str(e)))
-
-    @staticmethod
-    def _delete_snapshot(snapshot: Snapshot, output: AbstractOutputWriter):
-        try:
-            snapshot.delete()
-            output.write('- previous snapshot "%s" was deleted' % snapshot.name)
-        except Exception as e:
-            output.write('- previous snapshot "%s" was not deleted. Error: %s' % (snapshot.name, str(e)))
diff --git a/spotty/providers/aws/deployment/project_resources/ami_stack.py b/spotty/providers/aws/deployment/project_resources/ami_stack.py
deleted file mode 100644
index afd4fce..0000000
--- a/spotty/providers/aws/deployment/project_resources/ami_stack.py
+++ /dev/null
@@ -1,94 +0,0 @@
-import boto3
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.aws.aws_resources.stack import Stack
-
-
-class AmiStackResource(object):
-
-    def __init__(self, ami_name: str, region: str):
-        self._cf = boto3.client('cloudformation', region_name=region)
-        self._stack_name = 'spotty-ami-%s' % ami_name.lower()
-
-    @property
-    def name(self):
-        return self._stack_name
-
-    def get_stack(self) -> Stack:
-        return Stack.get_by_name(self._cf, self._stack_name)
-
-    def create_stack(self, template: str, parameters: dict, debug_mode: bool, output: AbstractOutputWriter):
-        """Creates an AMI stack and waits for the AMI to be created.
-
-        Args:
-            template: CloudFormation template
-            parameters: parameters for the template
-            debug_mode: if "True", NVIDIA Docker will be installed, but an AMI will not be created and the instance
-                        will not be terminated, so the user can connect to the instance for debugging.
-            output: output writer
-        """
-        stack = Stack.create_stack(
-            cf=self._cf,
-            StackName=self._stack_name,
-            TemplateBody=template,
-            Parameters=[{'ParameterKey': key, 'ParameterValue': value} for key, value in parameters.items()],
-            Capabilities=['CAPABILITY_IAM'],
-            OnFailure='DO_NOTHING' if debug_mode else 'DELETE',
-        )
-
-        output.write('Waiting for the AMI to be created...')
-
-        resource_messages = [
-            ('InstanceProfile', 'creating IAM role for the instance'),
-            ('Instance', 'launching the instance'),
-            ('InstanceReadyWaitCondition', 'installing NVIDIA Docker'),
-            ('AMICreatedWaitCondition', 'creating AMI and terminating the instance'),
-        ]
-
-        # wait for the stack to be created
-        with output.prefix('  '):
-            stack = stack.wait_status_changed(waiting_status='CREATE_IN_PROGRESS',
-                                              resource_messages=resource_messages,
-                                              resource_success_status='CREATE_COMPLETE', output=output)
-
-        if stack.status != 'CREATE_COMPLETE':
-            raise ValueError('Stack "%s" was not created.\n'
-                             'Please, see CloudFormation logs for the details.' % self._stack_name)
-
-        if debug_mode:
-            output.write('Stack "%s" was created in debug mode.' % self._stack_name)
-        else:
-            ami_id = [row['OutputValue'] for row in stack.outputs if row['OutputKey'] == 'NewAMI'][0]
-            output.write('\n'
-                         '--------------------------------------------------\n'
-                         'AMI "%s" (ID=%s) was successfully created.\n'
-                         'Use the "spotty start" command to run an instance.\n'
-                         '--------------------------------------------------'
-                         % (parameters['ImageName'], ami_id))
-
-    def delete_stack(self, output: AbstractOutputWriter, stack_id=None):
-        """Deletes an AMI stack.
-
-        Args:
-            output: output writer
-            stack_id: ID of the stack to delete (for older versions of Spotty)
-        """
-        # delete the image
-        stack = Stack.get_by_name(self._cf, stack_id) if stack_id else self.get_stack()
-        stack.delete()
-
-        output.write('Waiting for the AMI to be deleted...')
-
-        # wait for the deletion to be completed
-        with output.prefix('  '):
-            stack = stack.wait_status_changed(waiting_status='DELETE_IN_PROGRESS',
-                                              resource_messages=[],
-                                              resource_success_status='DELETE_COMPLETE', output=output)
-
-        if stack.status == 'DELETE_COMPLETE':
-            output.write('\n'
-                         '-----------------------------\n'
-                         'AMI was successfully deleted.\n'
-                         '-----------------------------')
-        else:
-            raise ValueError('Stack "%s" not deleted.\n'
-                             'See CloudFormation and CloudWatch logs for details.' % stack_id)
diff --git a/spotty/providers/aws/deployment/project_resources/bucket.py b/spotty/providers/aws/deployment/project_resources/bucket.py
deleted file mode 100644
index 1b419d0..0000000
--- a/spotty/providers/aws/deployment/project_resources/bucket.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import boto3
-import re
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.utils import random_string
-
-
-class BucketResource(object):
-
-    def __init__(self, project_name: str, region: str):
-        self._s3 = boto3.client('s3', region_name=region)
-        self._region = region
-        self._bucket_prefix = 'spotty-%s' % project_name.lower()
-
-    def _find_bucket(self):
-        res = self._s3.list_buckets()
-        regex = re.compile('-'.join([self._bucket_prefix, '[a-z0-9]{12}', self._region]))
-        buckets = [bucket['Name'] for bucket in res['Buckets'] if regex.match(bucket['Name']) is not None]
-
-        if len(buckets) > 1:
-            raise ValueError('Found several buckets in the same region: %s.' % ', '.join(buckets))
-
-        bucket_name = buckets[0] if len(buckets) else False
-
-        return bucket_name
-
-    def get_or_create_bucket(self, output: AbstractOutputWriter, dry_run=False):
-        bucket_name = self._find_bucket()
-        if not bucket_name:
-            bucket_name = '-'.join([self._bucket_prefix, random_string(12), self._region])
-            if not dry_run:
-                # a fix for the boto3 issue: https://github.com/boto/boto3/issues/125
-                if self._region == 'us-east-1':
-                    self._s3.create_bucket(ACL='private', Bucket=bucket_name)
-                else:
-                    self._s3.create_bucket(ACL='private', Bucket=bucket_name,
-                                           CreateBucketConfiguration={'LocationConstraint': self._region})
-
-            output.write('Bucket "%s" was created.' % bucket_name)
-
-        return bucket_name
-
-    def delete_bucket(self):
-        pass
diff --git a/spotty/providers/aws/deployment/project_resources/instance_stack.py b/spotty/providers/aws/deployment/project_resources/instance_stack.py
deleted file mode 100644
index 475ccee..0000000
--- a/spotty/providers/aws/deployment/project_resources/instance_stack.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import boto3
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.aws.aws_resources.instance import Instance
-from spotty.providers.aws.aws_resources.stack import Stack
-
-
-class InstanceStackResource(object):
-
-    def __init__(self, project_name: str, instance_name: str, region: str):
-        self._cf = boto3.client('cloudformation', region_name=region)
-        self._ec2 = boto3.client('ec2', region_name=region)
-        self._region = region
-        self._stack_name = 'spotty-instance-%s-%s' % (project_name.lower(), instance_name.lower())
-
-    @property
-    def name(self):
-        return self._stack_name
-
-    def get_instance(self):
-        return Instance.get_by_stack_name(self._ec2, self.name)
-
-    def create_or_update_stack(self, template: str, parameters: dict, output: AbstractOutputWriter):
-        """Runs CloudFormation template."""
-
-        # delete the stack if it exists
-        stack = Stack.get_by_name(self._cf, self._stack_name)
-        if stack:
-            self.delete_stack(output)
-
-        # create new stack
-        stack = Stack.create_stack(
-            cf=self._cf,
-            StackName=self._stack_name,
-            TemplateBody=template,
-            Parameters=[{'ParameterKey': key, 'ParameterValue': value} for key, value in parameters.items()],
-            Capabilities=['CAPABILITY_IAM'],
-            OnFailure='DO_NOTHING',
-        )
-
-        output.write('Waiting for the stack to be created...')
-
-        resource_messages = [
-            ('Instance', 'launching the instance'),
-            ('DockerReadyWaitCondition', 'waiting for the Docker container to be ready'),
-        ]
-
-        # wait for the stack to be created
-        with output.prefix('  '):
-            stack = stack.wait_status_changed(waiting_status='CREATE_IN_PROGRESS',
-                                              resource_messages=resource_messages,
-                                              resource_success_status='CREATE_COMPLETE', output=output)
-
-        if stack.status != 'CREATE_COMPLETE':
-            raise ValueError('Stack "%s" was not created.\n'
-                             'Please, see CloudFormation logs for the details.'
-                             % self._stack_name)
-
-        return stack
-
-    def delete_stack(self, output: AbstractOutputWriter, no_wait=False):
-        stack = Stack.get_by_name(self._cf, self._stack_name)
-        if not stack:
-            return
-
-        if not no_wait:
-            output.write('Waiting for the stack to be deleted...')
-
-        # delete the stack
-        try:
-            stack.delete()
-            if not no_wait:
-                stack.wait_stack_deleted()
-        except Exception as e:
-            raise ValueError('Stack "%s" was not deleted. Error: %s\n'
-                             'See CloudFormation logs for details.' % (self._stack_name, str(e)))
diff --git a/spotty/providers/aws/helpers/ami.py b/spotty/providers/aws/helpers/ami.py
new file mode 100644
index 0000000..56bf9f3
--- /dev/null
+++ b/spotty/providers/aws/helpers/ami.py
@@ -0,0 +1,39 @@
+from spotty.providers.aws.config.instance_config import DEFAULT_AMI_NAME
+from spotty.providers.aws.resources.image import Image
+
+
+def get_ami(ec2, ami_id: str = None, ami_name: str = None) -> Image:
+    """Returns an AMI that should be used for deployment.
+
+    Raises:
+        ValueError: If an AMI not found.
+    """
+    if ami_id:
+        # get an AMI by ID if the "amiId" parameter is specified
+        image = Image.get_by_id(ec2, ami_id)
+        if not image:
+            raise ValueError('AMI with ID=%s not found.' % ami_id)
+    elif ami_name:
+        # get an AMI by name if the "amiName" parameter is specified
+        image = Image.get_by_name(ec2, ami_name)
+        if not image:
+            # if an AMI name was explicitly specified in the config, but the AMI was not found, raise an error
+            raise ValueError('AMI with the name "%s" was not found.' % ami_name)
+    else:
+        # if the "amiName" parameter is not specified, try to use the default AMI name
+        image = Image.get_by_name(ec2, DEFAULT_AMI_NAME)
+        if not image:
+            # get the latest "Deep Learning Base AMI"
+            res = ec2.describe_images(
+                Owners=['amazon'],
+                Filters=[{'Name': 'name', 'Values': ['Deep Learning AMI (Ubuntu 16.04) Version*']}],
+            )
+
+            if not len(res['Images']):
+                raise ValueError('AWS Deep Learning AMI not found.\n'
+                                 'Use the "spotty aws create-ami" command to create an AMI with NVIDIA Docker.')
+
+            image_info = sorted(res['Images'], key=lambda x: x['CreationDate'], reverse=True)[0]
+            image = Image(ec2, image_info)
+
+    return image
diff --git a/spotty/providers/aws/helpers/availability_zone.py b/spotty/providers/aws/helpers/availability_zone.py
new file mode 100644
index 0000000..989ffce
--- /dev/null
+++ b/spotty/providers/aws/helpers/availability_zone.py
@@ -0,0 +1,38 @@
+from typing import List
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.providers.aws.config.ebs_volume import EbsVolume
+from spotty.providers.aws.resources.volume import Volume
+
+
+def update_availability_zone(ec2, availability_zone: str, volumes: List[AbstractInstanceVolume]):
+    """Checks that existing volumes located in the same AZ and the AZ from the
+    config file matches volumes AZ.
+
+    Args:
+        ec2: EC2 boto3 client
+        availability_zone: Availability Zone from the configuration.
+        volumes: List of volume objects.
+
+    Returns:
+        The final AZ where the instance should be run or an empty string if
+        the instance can be run in any AZ.
+
+    Raises:
+        ValueError: AZ in the config file doesn't match the AZs of the volumes or
+            AZs of the volumes are different.
+    """
+    availability_zone = availability_zone
+    for volume in volumes:
+        if isinstance(volume, EbsVolume):
+            ec2_volume = Volume.get_by_name(ec2, volume.ec2_volume_name)
+            if ec2_volume:
+                if availability_zone and (availability_zone != ec2_volume.availability_zone):
+                    raise ValueError(
+                        'The availability zone in the configuration file doesn\'t match the availability zone '
+                        'of the existing volume or you have two existing volumes in different availability '
+                        'zones.')
+
+                # update availability zone
+                availability_zone = ec2_volume.availability_zone
+
+    return availability_zone
diff --git a/spotty/providers/aws/helpers/aws_cli.py b/spotty/providers/aws/helpers/aws_cli.py
deleted file mode 100644
index a272b59..0000000
--- a/spotty/providers/aws/helpers/aws_cli.py
+++ /dev/null
@@ -1,89 +0,0 @@
-import json
-import logging
-import shlex
-import subprocess
-from shutil import which
-
-
-class AwsCommandError(Exception):
-    def __init__(self, msg: str):
-        super().__init__(msg)
-
-
-class AwsCli(object):
-    def __init__(self, profile: str = None, region: str = None):
-        self._profile = profile
-        self._region = region
-
-    def s3_sync(self, from_path: str, to_path: str, filters: list = None, exact_timestamp: bool = False,
-                delete: bool = False, capture_output: bool = True, dry_run: bool = False) -> str:
-        args = ['s3', 'sync', from_path, to_path]
-        args += self.get_s3_sync_arguments(filters, exact_timestamp=exact_timestamp, delete=delete, dry_run=dry_run)
-
-        return self._run(args, False, capture_output=capture_output)
-
-    @staticmethod
-    def get_s3_sync_arguments(filters: list = None, exact_timestamp: bool = False, delete: bool = False,
-                              quote: bool = False, dry_run: bool = False):
-        args = []
-
-        if filters:
-            for sync_filter in filters:
-                if ('exclude' in sync_filter and 'include' in sync_filter) \
-                        or ('exclude' not in sync_filter and 'include' not in sync_filter):
-                    raise ValueError('S3 sync filter has wrong format.')
-
-                if 'exclude' in sync_filter:
-                    for path in sync_filter['exclude']:
-                        args += ['--exclude', shlex.quote(path) if quote else path]
-
-                if 'include' in sync_filter:
-                    for path in sync_filter['include']:
-                        args += ['--include', shlex.quote(path) if quote else path]
-
-        if exact_timestamp:
-            args.append('--exact-timestamp')
-
-        if delete:
-            args.append('--delete')
-
-        if dry_run:
-            args.append('--dryrun')
-
-        return args
-
-    def _run(self, args: list, json_format=True, capture_output=True):
-        aws_cmd = 'aws'
-        if which(aws_cmd) is None:
-            raise ValueError('AWS CLI is not installed.')
-
-        command_args = [aws_cmd]
-        if self._profile:
-            command_args += ['--profile', self._profile]
-
-        if self._region:
-            command_args += ['--region', self._region]
-
-        if json_format:
-            command_args += ['--output', 'json']
-
-        command_args += args
-
-        logging.debug('AWS command: ' + subprocess.list2cmdline(command_args))
-
-        if capture_output:
-            res = subprocess.run(command_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-            output = res.stdout.decode('utf-8')
-
-            logging.debug('AWS command output: ' + output)
-
-            if res.returncode:
-                raise AwsCommandError(output)
-
-            if json_format:
-                output = json.loads(output)
-        else:
-            subprocess.run(command_args)
-            output = None
-
-        return output
diff --git a/spotty/providers/aws/helpers/download.py b/spotty/providers/aws/helpers/download.py
deleted file mode 100644
index 4b1033f..0000000
--- a/spotty/providers/aws/helpers/download.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import subprocess
-from spotty.helpers.ssh import get_ssh_command
-from spotty.providers.aws.helpers.aws_cli import AwsCli
-
-
-def get_tmp_instance_s3_path(bucket_name, instance_name):
-    return 's3://%s/download/instance-%s' % (bucket_name, instance_name)
-
-
-def upload_from_instance_to_s3(download_filters: list, host: str, port: int, user: str, key_path: str,
-                               dry_run: bool = False):
-    """Uploads files from the running instance to the S3 bucket.
-
-    It uses a temporary S3 directory that is unique for the instance. This
-    directory keeps downloaded from the instance files in order to sync only
-    changed files with local, not all of them every download).
-    """
-
-    # "sudo" should be called with the "-i" flag to use the root environment, so aws-cli will read
-    # the config file from the root home directory
-    args = ['sudo', '-i', '/tmp/spotty/instance/scripts/upload_files.sh']
-    args += AwsCli.get_s3_sync_arguments(filters=download_filters, delete=True, quote=True, dry_run=dry_run)
-
-    if not dry_run:
-        args += ['>', '/dev/null']
-
-    remote_cmd = subprocess.list2cmdline(args)
-
-    # connect to the instance and run the remote command
-    ssh_command = get_ssh_command(host, port, user, key_path, remote_cmd, quiet=not dry_run)
-    subprocess.call(ssh_command)
-
-
-def download_from_s3_to_local(bucket_name: str, instance_name: str, project_dir: str, region: str,
-                              download_filters: list, dry_run: bool = False):
-    """Downloads files from a temporary S3 directory to local."""
-    AwsCli(region=region).s3_sync(get_tmp_instance_s3_path(bucket_name, instance_name), project_dir,
-                                  filters=download_filters, exact_timestamp=True, capture_output=False,
-                                  dry_run=dry_run)
diff --git a/spotty/providers/aws/helpers/instance_prices.py b/spotty/providers/aws/helpers/instance_prices.py
new file mode 100644
index 0000000..9af7695
--- /dev/null
+++ b/spotty/providers/aws/helpers/instance_prices.py
@@ -0,0 +1,98 @@
+import datetime
+import json
+import logging
+import boto3
+from pkg_resources import resource_filename
+
+
+def get_spot_prices(ec2, instance_type: str):
+    """Returns current Spot Instance prices for all availability zones for particular instance type and region.
+    AWS region specified implicitly in the "ec2" object.
+    """
+    tomorrow_date = datetime.datetime.today() + datetime.timedelta(days=1)
+    res = ec2.describe_spot_price_history(InstanceTypes=[instance_type],
+                                          StartTime=tomorrow_date,
+                                          ProductDescriptions=['Linux/UNIX'])
+
+    prices_by_zone = {}
+    for row in res['SpotPriceHistory']:
+        prices_by_zone[row['AvailabilityZone']] = float(row['SpotPrice'])
+
+    return prices_by_zone
+
+
+def get_current_spot_price(ec2, instance_type, availability_zone=''):
+    """Returns the current Spot price for an availability zone.
+    If an availability zone is not specified, returns the minimum price for the region.
+    """
+    spot_prices = get_spot_prices(ec2, instance_type)
+    if availability_zone:
+        if availability_zone not in spot_prices:
+            raise ValueError('Spot price for the "%s" availability zone not found.' % availability_zone)
+
+        current_price = spot_prices[availability_zone]
+    else:
+        current_price = min(spot_prices.values())
+
+    return current_price
+
+
+def get_on_demand_price(instance_type: str, region: str):
+    client = boto3.client('pricing', region_name='us-east-1')  # the API available only in "us-east-1"
+
+    try:
+        response = client.get_products(
+            ServiceCode='AmazonEC2',
+            Filters=[
+                {'Type': 'TERM_MATCH', 'Field': 'location', 'Value': _get_region_name(region)},
+                {'Type': 'TERM_MATCH', 'Field': 'instanceType', 'Value': instance_type},
+                {'Type': 'TERM_MATCH', 'Field': 'operatingSystem', 'Value': 'Linux'},
+                {'Type': 'TERM_MATCH', 'Field': 'tenancy', 'Value': 'shared'},
+                {'Type': 'TERM_MATCH', 'Field': 'capacitystatus', 'Value': 'Used'},
+            ],
+        )
+
+        prices = json.loads(response['PriceList'][0])['terms']['OnDemand']
+        price = float(list(list(prices.values())[0]['priceDimensions'].values())[0]['pricePerUnit']['USD'])
+    except Exception as e:
+        logging.debug('Couldn\'t find a price for the instance: ' + str(e))
+        price = None
+
+    return price
+
+
+def _get_region_name(region: str):
+    endpoint_file = resource_filename('botocore', 'data/endpoints.json')
+    try:
+        with open(endpoint_file, 'r') as f:
+            data = json.load(f)
+            region_name = data['partitions'][0]['regions'][region]['description']
+    except Exception as e:
+        logging.debug('Couldn\'t obtain the region name: ' + str(e))
+        region_name = None
+
+    return region_name
+
+
+def check_max_spot_price(ec2, instance_type: str, is_spot_instance: bool, max_price: float,
+                         availability_zone: str = ''):
+    """Checks that the specified maximum Spot price is less than the
+    current Spot price.
+
+    Args:
+        ec2: EC2 client
+        instance_type (str): Instance Type
+        is_spot_instance (bool): True if it's a spot instance
+        max_price (float): requested maximum price for the instance
+        availability_zone (str): Availability zone to check. If it's an empty string,
+            checks the cheapest AZ.
+
+    Raises:
+        ValueError: Current price for the instance is higher than the
+            maximum price in the configuration file.
+    """
+    if is_spot_instance and max_price:
+        current_price = get_current_spot_price(ec2, instance_type, availability_zone)
+        if current_price > max_price:
+            raise ValueError('Current price for the instance (%.04f) is higher than the maximum price in the '
+                             'configuration file (%.04f).' % (current_price, max_price))
diff --git a/spotty/providers/aws/helpers/logs.py b/spotty/providers/aws/helpers/logs.py
new file mode 100644
index 0000000..99db759
--- /dev/null
+++ b/spotty/providers/aws/helpers/logs.py
@@ -0,0 +1,24 @@
+import os
+import subprocess
+import tempfile
+from glob import glob
+from spotty.providers.aws.helpers.s3_sync import get_s3_sync_command
+
+
+def get_logs_s3_path(bucket_name: str, instance_name: str) -> str:
+    return 's3://%s/logs/aws/%s' % (bucket_name, instance_name)
+
+
+def download_logs(bucket_name: str, instance_name: str, stack_uuid: str, region: str) -> list:
+    """Downloads logs from S3 bucket to temporary directory."""
+    logs_s3_path = '%s/%s' % (get_logs_s3_path(bucket_name, instance_name), stack_uuid)
+    local_logs_dir = tempfile.mkdtemp()
+
+    # download logs
+    download_cmd = get_s3_sync_command(logs_s3_path, local_logs_dir, region=region, exact_timestamp=True, quiet=True)
+    subprocess.call(download_cmd, shell=True)
+
+    # get paths to the downloaded files
+    log_paths = glob(os.path.join(local_logs_dir, '**', '*'), recursive=True)
+
+    return log_paths
diff --git a/spotty/providers/aws/helpers/s3_sync.py b/spotty/providers/aws/helpers/s3_sync.py
new file mode 100644
index 0000000..c0f4f42
--- /dev/null
+++ b/spotty/providers/aws/helpers/s3_sync.py
@@ -0,0 +1,53 @@
+from shutil import which
+from spotty.deployment.utils.cli import shlex_join
+
+
+def check_aws_installed():
+    """Checks that AWS CLI is installed."""
+    if which('aws') is None:
+        raise ValueError('AWS CLI is not installed.')
+
+
+def get_s3_sync_command(from_path: str, to_path: str, profile: str = None, region: str = None, filters: list = None,
+                        exact_timestamp: bool = False, delete: bool = False, quiet: bool = False,
+                        dry_run: bool = False):
+    """Builds an "aws s3 sync" command."""
+    args = ['aws']
+
+    if profile:
+        args += ['--profile', profile]
+
+    if region:
+        args += ['--region', region]
+
+    args += ['s3', 'sync', from_path, to_path]
+
+    if filters:
+        for sync_filter in filters:
+            if ('exclude' in sync_filter and 'include' in sync_filter) \
+                    or ('exclude' not in sync_filter and 'include' not in sync_filter):
+                raise ValueError('S3 sync filter has wrong format.')
+
+            if 'exclude' in sync_filter:
+                for path in sync_filter['exclude']:
+                    args += ['--exclude', path]
+
+            if 'include' in sync_filter:
+                for path in sync_filter['include']:
+                    args += ['--include', path]
+
+    if exact_timestamp:
+        args.append('--exact-timestamp')
+
+    if delete:
+        args.append('--delete')
+
+    if quiet:
+        args.append('--quiet')
+
+    if dry_run:
+        args.append('--dryrun')
+
+    command = shlex_join(args)
+
+    return command
diff --git a/spotty/providers/aws/helpers/spot_prices.py b/spotty/providers/aws/helpers/spot_prices.py
deleted file mode 100644
index d0937b5..0000000
--- a/spotty/providers/aws/helpers/spot_prices.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import datetime
-
-
-def get_spot_prices(ec2, instance_type: str):
-    """Returns current Spot Instance prices for all availability zones for particular instance type and region.
-    AWS region specified implicitly in the "ec2" object.
-    """
-    tomorrow_date = datetime.datetime.today() + datetime.timedelta(days=1)
-    res = ec2.describe_spot_price_history(InstanceTypes=[instance_type],
-                                          StartTime=tomorrow_date,
-                                          ProductDescriptions=['Linux/UNIX'])
-
-    prices_by_zone = {}
-    for row in res['SpotPriceHistory']:
-        prices_by_zone[row['AvailabilityZone']] = float(row['SpotPrice'])
-
-    return prices_by_zone
-
-
-def get_current_spot_price(ec2, instance_type, availability_zone=''):
-    """Returns the current Spot price for an availability zone.
-    If an availability zone is not specified, returns the minimum price for the region.
-    """
-    spot_prices = get_spot_prices(ec2, instance_type)
-    if availability_zone:
-        if availability_zone not in spot_prices:
-            raise ValueError('Spot price for the "%s" availability zone not found.' % availability_zone)
-
-        current_price = spot_prices[availability_zone]
-    else:
-        current_price = min(spot_prices.values())
-
-    return current_price
diff --git a/spotty/providers/aws/deployment/checks.py b/spotty/providers/aws/helpers/subnet.py
similarity index 64%
rename from spotty/providers/aws/deployment/checks.py
rename to spotty/providers/aws/helpers/subnet.py
index 5328b4f..c140d79 100644
--- a/spotty/providers/aws/deployment/checks.py
+++ b/spotty/providers/aws/helpers/subnet.py
@@ -1,5 +1,4 @@
-from spotty.providers.aws.aws_resources.subnet import Subnet
-from spotty.providers.aws.helpers.spot_prices import get_current_spot_price
+from spotty.providers.aws.resources.subnet import Subnet
 
 
 def check_az_and_subnet(ec2, region: str, availability_zone: str, subnet_id: str):
@@ -39,26 +38,3 @@ def check_az_and_subnet(ec2, region: str, availability_zone: str, subnet_id: str
                 raise ValueError('Default subnets for the following availability zones were not found: %s.\n'
                                  'Use "subnetId" and "availabilityZone" parameters or create missing default '
                                  'subnets.' % ', '.join(zones_wo_subnet))
-
-
-def check_max_price(ec2, instance_type: str, on_demand: bool, max_price: float, availability_zone: str = ''):
-    """Checks that the specified maximum Spot price is less than the
-    current Spot price.
-
-    Args:
-        ec2: EC2 client
-        instance_type (str): Instance Type
-        on_demand (bool): True if it's on-demand instance
-        max_price (float): requested maximum price for the instance
-        availability_zone (str): Availability zone to check. If it's an empty string,
-            checks the cheapest AZ.
-
-    Raises:
-        ValueError: Current price for the instance is higher than the
-            maximum price in the configuration file.
-    """
-    if not on_demand and max_price:
-        current_price = get_current_spot_price(ec2, instance_type, availability_zone)
-        if current_price > max_price:
-            raise ValueError('Current price for the instance (%.04f) is higher than the maximum price in the '
-                             'configuration file (%.04f).' % (current_price, max_price))
diff --git a/spotty/providers/aws/helpers/sync.py b/spotty/providers/aws/helpers/sync.py
deleted file mode 100644
index 033284e..0000000
--- a/spotty/providers/aws/helpers/sync.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import subprocess
-from spotty.helpers.ssh import get_ssh_command
-from spotty.providers.aws.helpers.aws_cli import AwsCli
-
-
-def get_project_s3_path(bucket_name: str):
-    return 's3://%s/project' % bucket_name
-
-
-def get_instance_sync_arguments(sync_filters: list):
-    """Returns arguments for the "aws sync" command that will be run the instance side."""
-    return AwsCli.get_s3_sync_arguments(sync_filters, exact_timestamp=True, quote=True)
-
-
-def sync_project_with_s3(project_dir, bucket_name, region, sync_filters, dry_run=False):
-    # sync the project with S3, deleted files will be deleted from S3
-    AwsCli(region=region).s3_sync(project_dir, get_project_s3_path(bucket_name), filters=sync_filters, delete=True,
-                                  capture_output=False, dry_run=dry_run)
-
-
-def sync_instance_with_s3(sync_filters: list, host: str, port: int, user: str, key_path: str):
-    """Syncs the project from the S3 bucket to the instance."""
-
-    # "sudo" should be called with the "-i" flag to use the root environment, so aws-cli will read
-    # the config file from the root home directory
-    remote_cmd = subprocess.list2cmdline(['sudo', '-i', '/tmp/spotty/instance/scripts/sync_project.sh',
-                                          *get_instance_sync_arguments(sync_filters), '>', '/dev/null'])
-
-    # connect to the instance and run remote command
-    ssh_command = get_ssh_command(host, port, user, key_path, remote_cmd, quiet=True)
-    subprocess.call(ssh_command)
diff --git a/spotty/providers/aws/helpers/vpc.py b/spotty/providers/aws/helpers/vpc.py
new file mode 100644
index 0000000..a23d563
--- /dev/null
+++ b/spotty/providers/aws/helpers/vpc.py
@@ -0,0 +1,16 @@
+from spotty.providers.aws.resources.subnet import Subnet
+from spotty.providers.aws.resources.vpc import Vpc
+
+
+def get_vpc_id(ec2, subnet_id: str = None) -> str:
+    """Returns VPC ID that should be used for deployment."""
+    if subnet_id:
+        vpc_id = Subnet.get_by_id(ec2, subnet_id).vpc_id
+    else:
+        default_vpc = Vpc.get_default_vpc(ec2)
+        if not default_vpc:
+            raise ValueError('Default VPC not found')
+
+        vpc_id = default_vpc.vpc_id
+
+    return vpc_id
diff --git a/spotty/providers/aws/instance_deployment.py b/spotty/providers/aws/instance_deployment.py
new file mode 100644
index 0000000..fbe40e3
--- /dev/null
+++ b/spotty/providers/aws/instance_deployment.py
@@ -0,0 +1,139 @@
+import boto3
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.deployment.abstract_cloud_instance.abstract_instance_deployment import AbstractInstanceDeployment
+from spotty.deployment.container.docker.docker_commands import DockerCommands
+from spotty.providers.aws.cfn_templates.instance.template import prepare_instance_template, get_template_parameters
+from spotty.providers.aws.data_transfer import DataTransfer
+from spotty.providers.aws.helpers.availability_zone import update_availability_zone
+from spotty.providers.aws.helpers.instance_prices import check_max_spot_price
+from spotty.providers.aws.helpers.subnet import check_az_and_subnet
+from spotty.providers.aws.resource_managers.key_pair_manager import KeyPairManager
+from spotty.deployment.utils.print_info import render_volumes_info_table
+from spotty.providers.aws.resources.instance import Instance
+from spotty.providers.aws.config.instance_config import InstanceConfig
+from spotty.providers.aws.deletion_policies import apply_deletion_policies
+from spotty.providers.aws.resource_managers.instance_profile_stack_manager import InstanceProfileStackManager
+from spotty.providers.aws.helpers.logs import download_logs
+from spotty.providers.aws.resource_managers.instance_stack_manager import InstanceStackManager
+
+
+class InstanceDeployment(AbstractInstanceDeployment):
+
+    instance_config: InstanceConfig
+
+    def __init__(self, instance_config: InstanceConfig):
+        super().__init__(instance_config)
+
+        self._project_name = instance_config.project_config.project_name
+        self._ec2 = boto3.client('ec2', region_name=instance_config.region)
+
+    @property
+    def stack_manager(self) -> InstanceStackManager:
+        return InstanceStackManager(self._project_name, self.instance_config.name, self.instance_config.region)
+
+    @property
+    def key_pair_manager(self) -> KeyPairManager:
+        return KeyPairManager(self._ec2, self._project_name, self.instance_config.region)
+
+    def get_instance(self) -> Instance:
+        return Instance.get_by_stack_name(self._ec2, self.stack_manager.name)
+
+    def deploy(self, container_commands: DockerCommands, bucket_name: str,
+               data_transfer: DataTransfer, output: AbstractOutputWriter, dry_run: bool = False):
+        # get deployment availability zone
+        availability_zone = update_availability_zone(self._ec2, self.instance_config.availability_zone,
+                                                     self.instance_config.volumes)
+
+        # check availability zone and subnet configuration
+        check_az_and_subnet(self._ec2, self.instance_config.region, availability_zone, self.instance_config.subnet_id)
+
+        # check the maximum price for a spot instance
+        check_max_spot_price(self._ec2, self.instance_config.instance_type, self.instance_config.is_spot_instance,
+                             self.instance_config.max_price, availability_zone)
+
+        # sync the project with the S3 bucket
+        if bucket_name is not None:
+            output.write('Syncing the project with the S3 bucket...')
+            data_transfer.upload_local_to_bucket(bucket_name, dry_run=dry_run)
+
+        # create or update instance profile
+        if not dry_run:
+            instance_profile_stack_manager = InstanceProfileStackManager(
+                self._project_name, self.instance_config.name, self.instance_config.region)
+            if not self.instance_config.instance_profile_arn:
+                instance_profile_arn = instance_profile_stack_manager.create_or_update_stack(
+                    self.instance_config.managed_policy_arns, output=output)
+            else:
+                instance_profile_arn = self.instance_config.instance_profile_arn
+        else:
+            instance_profile_arn = None
+
+        # create a key pair if it doesn't exist
+        if not dry_run:
+            self.key_pair_manager.maybe_create_key()
+
+        output.write('Preparing CloudFormation template...')
+
+        # prepare CloudFormation template
+        with output.prefix('  '):
+            template = prepare_instance_template(
+                ec2=self._ec2,
+                instance_config=self.instance_config,
+                docker_commands=container_commands,
+                availability_zone=availability_zone,
+                sync_project_cmd=data_transfer.get_download_bucket_to_instance_command(bucket_name=bucket_name),
+                output=output,
+            )
+
+            # get parameters for the template
+            parameters = get_template_parameters(
+                ec2=self._ec2,
+                instance_config=self.instance_config,
+                instance_profile_arn=instance_profile_arn,
+                bucket_name=bucket_name,
+                key_pair_name=self.key_pair_manager.key_name,
+                output=output,
+            )
+
+        # print information about the volumes
+        output.write('\nVolumes:\n%s\n'
+                     % render_volumes_info_table(self.instance_config.volume_mounts, self.instance_config.volumes))
+
+        # create stack
+        if not dry_run:
+            stack = self.stack_manager.create_or_update_stack(template, parameters, self.instance_config, output)
+            if stack.status != 'CREATE_COMPLETE':
+                logs_str = 'Please, see CloudFormation logs for the details.'
+
+                # download CloudFormation logs from the instance if it was created
+                if self.get_instance():
+                    log_paths = download_logs(
+                        bucket_name=bucket_name,
+                        instance_name=self.instance_config.name,
+                        stack_uuid=stack.stack_uuid,
+                        region=self.instance_config.region,
+                    )
+
+                    logs_str = 'Please, see the logs for the details:\n  '
+                    logs_str += '\n  '.join(log_paths)
+
+                raise ValueError('Stack "%s" was not created.\n%s' % (stack.name, logs_str))
+
+    def delete(self, output: AbstractOutputWriter):
+        # terminate the instance
+        instance = self.get_instance()
+        if instance:
+            output.write('Terminating the instance... ', newline=False)
+            instance.terminate()
+            output.write('DONE')
+        else:
+            output.write('The instance was already terminated.')
+
+        # delete the stack in background if it exists
+        self.stack_manager.delete_stack(output, no_wait=True)
+
+        output.write('Applying deletion policies for the volumes...')
+
+        # apply deletion policies for the volumes
+        with output.prefix('  '):
+            apply_deletion_policies(self._ec2, self.instance_config.volumes, output)
diff --git a/spotty/providers/aws/instance_manager.py b/spotty/providers/aws/instance_manager.py
index 2a76a03..bfd3d92 100644
--- a/spotty/providers/aws/instance_manager.py
+++ b/spotty/providers/aws/instance_manager.py
@@ -1,94 +1,40 @@
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
 from spotty.errors.instance_not_running import InstanceNotRunningError
+from spotty.deployment.abstract_cloud_instance.abstract_cloud_instance_manager import AbstractCloudInstanceManager
+from spotty.providers.aws.resource_managers.bucket_manager import BucketManager
 from spotty.providers.aws.config.instance_config import InstanceConfig
-from spotty.providers.aws.deployment.ami_deployment import AmiDeployment
-from spotty.providers.aws.deployment.instance_deployment import InstanceDeployment
-from spotty.providers.aws.helpers.download import download_from_s3_to_local, upload_from_instance_to_s3
-from spotty.providers.aws.helpers.sync import sync_project_with_s3, sync_instance_with_s3
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
+from spotty.providers.aws.data_transfer import DataTransfer
+from spotty.providers.aws.instance_deployment import InstanceDeployment
 from spotty.utils import render_table
 
 
-class InstanceManager(AbstractInstanceManager):
+class InstanceManager(AbstractCloudInstanceManager):
 
-    @property
-    def instance_deployment(self) -> InstanceDeployment:
-        """Returns an instance deployment manager."""
-        return InstanceDeployment(self.project_config.project_name, self.instance_config)
-
-    @property
-    def ami_deployment(self) -> AmiDeployment:
-        """Returns an AMI deployment manager."""
-        return AmiDeployment(self.project_config.project_name, self.instance_config)
+    instance_config: InstanceConfig
+    bucket_manager: BucketManager
+    data_transfer: DataTransfer
+    instance_deployment: InstanceDeployment
 
-    def _get_instance_config(self, config: dict) -> InstanceConfig:
+    def _get_instance_config(self, instance_config: dict) -> InstanceConfig:
         """Validates the instance config and returns an InstanceConfig object."""
-        return InstanceConfig(config)
-
-    @property
-    def instance_config(self) -> InstanceConfig:
-        """This property is redefined just for a correct type hinting."""
-        return self._instance_config
-
-    def is_running(self):
-        """Checks if the instance is running."""
-        return bool(self.instance_deployment.get_instance())
-
-    def start(self, output: AbstractOutputWriter, dry_run=False):
-        deployment = self.instance_deployment
-
-        if not dry_run:
-            # check if the instance is already running
-            instance = deployment.get_instance()
-            if instance:
-                print('Instance is already running. Are you sure you want to restart it?')
-                res = input('Type "y" to confirm: ')
-                if res != 'y':
-                    raise ValueError('The operation was cancelled.')
-
-                # terminating the instance to make EBS volumes available
-                output.write('Terminating the instance...')
-                instance.terminate()
-                instance.wait_instance_terminated()
-
-        # deploy the instance
-        deployment.deploy(self.project_config, output, dry_run=dry_run)
-
-    def stop(self, output: AbstractOutputWriter):
-        # delete the stack and apply deletion policies
-        self.instance_deployment.delete(output)
-
-    def clean(self, output: AbstractOutputWriter):
-        pass
-
-    def sync(self, output: AbstractOutputWriter, dry_run=False):
-        # create or get existing bucket for the project
-        bucket_name = self.instance_deployment.bucket.get_or_create_bucket(output, dry_run)
-
-        # sync the project with S3 bucket
-        output.write('Syncing the project with S3 bucket...')
-        sync_project_with_s3(self.project_config.project_dir, bucket_name, self.instance_config.region,
-                             self.project_config.sync_filters, dry_run=dry_run)
-
-        if not dry_run:
-            # sync S3 with the instance
-            output.write('Syncing S3 bucket with the instance...')
-            sync_instance_with_s3(self.project_config.sync_filters, self.get_ip_address(), self.ssh_port, self.ssh_user,
-                                  self.ssh_key_path)
-
-    def download(self, download_filters: list, output: AbstractOutputWriter, dry_run=False):
-        # create or get existing bucket for the project
-        bucket_name = self.instance_deployment.bucket.get_or_create_bucket(output, dry_run)
-
-        # sync files from the instance to a temporary S3 directory
-        output.write('Uploading files from the instance to S3 bucket...')
-        upload_from_instance_to_s3(download_filters, self.get_ip_address(), self.ssh_port, self.ssh_user, self.ssh_key_path,
-                                   dry_run=dry_run)
-
-        # sync the project with the S3 bucket
-        output.write('Downloading files from S3 bucket to local...')
-        download_from_s3_to_local(bucket_name, self.instance_config.name, self.project_config.project_dir,
-                                  self.instance_config.region, download_filters, dry_run=dry_run)
+        return InstanceConfig(instance_config, self.project_config)
+
+    def _get_bucket_manager(self) -> BucketManager:
+        """Returns an bucket manager."""
+        return BucketManager(self.instance_config.project_config.project_name, self.instance_config.region)
+
+    def _get_data_transfer(self) -> DataTransfer:
+        """Returns a data transfer object."""
+        return DataTransfer(
+            local_project_dir=self.project_config.project_dir,
+            host_project_dir=self.instance_config.host_project_dir,
+            sync_filters=self.project_config.sync_filters,
+            instance_name=self.instance_config.name,
+            region=self.instance_config.region,
+        )
+
+    def _get_instance_deployment(self) -> InstanceDeployment:
+        """Returns an instance deployment manager."""
+        return InstanceDeployment(self.instance_config)
 
     def get_status_text(self):
         instance = self.instance_deployment.get_instance()
@@ -104,29 +50,17 @@ def get_status_text(self):
         if instance.public_ip_address:
             table.append(('Public IP Address', instance.public_ip_address))
 
-        table.append(('Launch Time', instance.launch_time.strftime('%Y-%m-%d %H:%M:%S')))
-
         if instance.lifecycle == 'spot':
             spot_price = instance.get_spot_price()
             table.append(('Purchasing Option', 'Spot Instance'))
             table.append(('Spot Instance Price', '$%.04f' % spot_price))
         else:
+            on_demand_price = instance.get_on_demand_price()
             table.append(('Purchasing Option', 'On-Demand Instance'))
+            table.append(('Instance Price', ('$%.04f (us-east-1)' % on_demand_price) if on_demand_price else 'Unknown'))
 
         return render_table(table)
 
-    def get_public_ip_address(self):
-        """Returns a public IP address of the running instance."""
-        instance = self.instance_deployment.get_instance()
-        if not instance:
-            raise InstanceNotRunningError(self.instance_config.name)
-
-        return instance.public_ip_address
-
-    @property
-    def ssh_user(self):
-        return 'ubuntu'
-
     @property
     def ssh_key_path(self):
-        return self.instance_deployment.key_pair.key_path
+        return self.instance_deployment.key_pair_manager.key_path
diff --git a/spotty/providers/aws/resource_managers/__init__.py b/spotty/providers/aws/resource_managers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/spotty/providers/aws/resource_managers/bucket_manager.py b/spotty/providers/aws/resource_managers/bucket_manager.py
new file mode 100644
index 0000000..d2ef7e8
--- /dev/null
+++ b/spotty/providers/aws/resource_managers/bucket_manager.py
@@ -0,0 +1,47 @@
+import boto3
+import re
+from spotty.deployment.abstract_cloud_instance.abstract_bucket_manager import AbstractBucketManager
+from spotty.deployment.abstract_cloud_instance.errors.bucket_not_found import BucketNotFoundError
+from spotty.providers.aws.resources.bucket import Bucket
+from spotty.utils import random_string
+
+
+class BucketManager(AbstractBucketManager):
+
+    def __init__(self, project_name: str, region: str):
+        super().__init__(project_name)
+
+        self._s3 = boto3.client('s3', region_name=region)
+        self._region = region
+        self._bucket_prefix = 'spotty-%s' % project_name.lower()
+
+    def get_bucket(self) -> Bucket:
+        res = self._s3.list_buckets()
+        regex = re.compile('-'.join([self._bucket_prefix, '[a-z0-9]{12}', self._region]))
+        buckets = [bucket for bucket in res['Buckets'] if regex.match(bucket['Name']) is not None]
+
+        if len(buckets) > 1:
+            raise ValueError('Found several buckets in the same region: %s.'
+                             % ', '.join(bucket['Name'] for bucket in buckets))
+
+        if not len(buckets):
+            raise BucketNotFoundError
+
+        bucket = Bucket(buckets[0])
+
+        return bucket
+
+    def create_bucket(self) -> Bucket:
+        bucket_name = '-'.join([self._bucket_prefix, random_string(12), self._region])
+
+        # a fix for the boto3 issue: https://github.com/boto/boto3/issues/125
+        if self._region == 'us-east-1':
+            self._s3.create_bucket(ACL='private', Bucket=bucket_name)
+        else:
+            self._s3.create_bucket(ACL='private', Bucket=bucket_name,
+                                   CreateBucketConfiguration={'LocationConstraint': self._region})
+
+        return Bucket({'Name': bucket_name})
+
+    def delete_bucket(self):
+        pass
diff --git a/spotty/providers/aws/deployment/project_resources/instance_profile_stack.py b/spotty/providers/aws/resource_managers/instance_profile_stack_manager.py
similarity index 93%
rename from spotty/providers/aws/deployment/project_resources/instance_profile_stack.py
rename to spotty/providers/aws/resource_managers/instance_profile_stack_manager.py
index b97f215..43e14f4 100644
--- a/spotty/providers/aws/deployment/project_resources/instance_profile_stack.py
+++ b/spotty/providers/aws/resource_managers/instance_profile_stack_manager.py
@@ -1,11 +1,11 @@
 import boto3
 from botocore.exceptions import ClientError, WaiterError
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.aws.aws_resources.stack import Stack
-from spotty.providers.aws.deployment.cf_templates.instance_profile_template import prepare_instance_profile_template
+from spotty.providers.aws.cfn_templates.instance_profile.template import prepare_instance_profile_template
+from spotty.providers.aws.resources.stack import Stack
 
 
-class InstanceProfileStackResource(object):
+class InstanceProfileStackManager(object):
 
     def __init__(self, project_name: str, instance_name: str, region: str):
         self._cf = boto3.client('cloudformation', region_name=region)
diff --git a/spotty/providers/aws/resource_managers/instance_stack_manager.py b/spotty/providers/aws/resource_managers/instance_stack_manager.py
new file mode 100644
index 0000000..dff54c2
--- /dev/null
+++ b/spotty/providers/aws/resource_managers/instance_stack_manager.py
@@ -0,0 +1,120 @@
+import boto3
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.providers.aws.resources.stack import Stack, Task
+from spotty.providers.aws.config.instance_config import InstanceConfig
+
+
+class InstanceStackManager(object):
+
+    def __init__(self, project_name: str, instance_name: str, region: str):
+        self._cf = boto3.client('cloudformation', region_name=region)
+        self._ec2 = boto3.client('ec2', region_name=region)
+        self._region = region
+        self._stack_name = 'spotty-instance-%s-%s' % (project_name.lower(), instance_name.lower())
+
+    @property
+    def name(self):
+        return self._stack_name
+
+    def create_or_update_stack(self, template: str, parameters: dict, instance_config: InstanceConfig,
+                               output: AbstractOutputWriter):
+        """Runs CloudFormation template."""
+
+        # delete the stack if it exists
+        stack = Stack.get_by_name(self._cf, self._stack_name)
+        if stack:
+            self.delete_stack(output)
+
+        # create new stack
+        stack = Stack.create_stack(
+            cf=self._cf,
+            StackName=self._stack_name,
+            TemplateBody=template,
+            Parameters=[{'ParameterKey': key, 'ParameterValue': value} for key, value in parameters.items()],
+            Capabilities=['CAPABILITY_IAM'],
+            OnFailure='DO_NOTHING',
+        )
+
+        output.write('Waiting for the stack to be created...')
+
+        tasks = [
+            Task(
+                message='launching the instance',
+                start_resource=None,
+                finish_resource='Instance',
+                enabled=True,
+            ),
+            Task(
+                message='preparing the instance',
+                start_resource='Instance',
+                finish_resource='MountingVolumesSignal',
+                enabled=True,
+            ),
+            Task(
+                message='mounting volumes',
+                start_resource='MountingVolumesSignal',
+                finish_resource='SettingDockerRootSignal',
+                enabled=bool(instance_config.volumes),
+            ),
+            Task(
+                message='setting Docker data root',
+                start_resource='SettingDockerRootSignal',
+                finish_resource='SyncingProjectSignal',
+                enabled=bool(instance_config.docker_data_root),
+            ),
+            Task(
+                message='syncing project files',
+                start_resource='SyncingProjectSignal',
+                finish_resource='RunningInstanceStartupCommandsSignal',
+                enabled=True,
+            ),
+            Task(
+                message='running instance startup commands',
+                start_resource='RunningInstanceStartupCommandsSignal',
+                finish_resource='BuildingDockerImageSignal',
+                enabled=bool(instance_config.commands),
+            ),
+            Task(
+                message='building Docker image',
+                start_resource='BuildingDockerImageSignal',
+                finish_resource='StartingContainerSignal',
+                enabled=bool(instance_config.dockerfile_path),
+            ),
+            Task(
+                message='starting container',
+                start_resource='StartingContainerSignal',
+                finish_resource='RunningContainerStartupCommandsSignal',
+                enabled=True,
+            ),
+            Task(
+                message='running container startup commands',
+                start_resource='RunningContainerStartupCommandsSignal',
+                finish_resource='DockerReadyWaitCondition',
+                enabled=bool(instance_config.container_config.commands),
+            ),
+        ]
+
+        # wait for the stack to be created
+        with output.prefix('  '):
+            stack.wait_tasks(tasks, resource_success_status='CREATE_COMPLETE', resource_fail_status='CREATE_FAILED',
+                             output=output)
+            stack = stack.wait_status_changed(stack_waiting_status='CREATE_IN_PROGRESS', output=output)
+
+        return stack
+
+    def delete_stack(self, output: AbstractOutputWriter, no_wait=False):
+        stack = Stack.get_by_name(self._cf, self._stack_name)
+        if not stack:
+            return
+
+        if not no_wait:
+            output.write('Waiting for the stack to be deleted...')
+
+        # delete the stack
+        try:
+            stack.delete()
+            if not no_wait:
+                stack.wait_stack_deleted()
+        except Exception as e:
+            raise ValueError('Stack "%s" was not deleted. Error: %s\n'
+                             'See CloudFormation logs for details.' % (self._stack_name, str(e)))
diff --git a/spotty/providers/aws/deployment/project_resources/key_pair.py b/spotty/providers/aws/resource_managers/key_pair_manager.py
similarity index 54%
rename from spotty/providers/aws/deployment/project_resources/key_pair.py
rename to spotty/providers/aws/resource_managers/key_pair_manager.py
index e02f352..32d0b98 100644
--- a/spotty/providers/aws/deployment/project_resources/key_pair.py
+++ b/spotty/providers/aws/resource_managers/key_pair_manager.py
@@ -1,33 +1,26 @@
 import os
-import boto3
 from spotty.configuration import get_spotty_keys_dir
+from spotty.providers.instance_manager_factory import PROVIDER_AWS
 
 
-class KeyPairResource(object):
+class KeyPairManager(object):
 
-    def __init__(self, project_name: str, region: str, provider_name: str):
-        self._ec2 = boto3.client('ec2', region_name=region)
-        self._region = region
+    def __init__(self, ec2, project_name: str, region: str):
+        self._ec2 = ec2
         self._key_name = 'spotty-key-%s-%s' % (project_name.lower(), region)
-        self._new_key_path = os.path.join(get_spotty_keys_dir(), provider_name, self._key_name)
-        # TODO: remove from future versions of Spotty
-        self._old_key_path = os.path.join(get_spotty_keys_dir(), self._key_name)
+        self._key_path = os.path.join(get_spotty_keys_dir(PROVIDER_AWS), self._key_name)
 
     @property
-    def key_path(self):
-        if os.path.isfile(self._old_key_path) and not os.path.isfile(self._new_key_path):
-            key_path = self._old_key_path
-        else:
-            key_path = self._new_key_path
+    def key_name(self):
+        return self._key_name
 
-        return key_path
+    @property
+    def key_path(self):
+        return self._key_path
 
-    def get_or_create_key(self, dry_run=False):
-        if dry_run:
-            return self._key_name
+    def maybe_create_key(self):
 
-        key_path = self.key_path
-        key_file_exists = os.path.isfile(key_path)
+        key_file_exists = os.path.isfile(self.key_path)
         ec2_key_exists = self._ec2_key_exists()
 
         if not ec2_key_exists or not key_file_exists:
@@ -37,23 +30,21 @@ def get_or_create_key(self, dry_run=False):
 
             # remove the key file (in case it was the old path)
             if key_file_exists:
-                os.unlink(key_path)
+                os.unlink(self.key_path)
 
             # create new key
             res = self._ec2.create_key_pair(KeyName=self._key_name)
 
             # create a provider subdirectory
-            keys_dir = os.path.dirname(self._new_key_path)
+            keys_dir = os.path.dirname(self.key_path)
             if not os.path.isdir(keys_dir):
                 os.makedirs(keys_dir, mode=0o755, exist_ok=True)
 
             # save the key to the new path
-            with open(self._new_key_path, 'w') as f:
+            with open(self.key_path, 'w') as f:
                 f.write(res['KeyMaterial'])
 
-            os.chmod(key_path, 0o600)
-
-        return self._key_name
+            os.chmod(self.key_path, 0o600)
 
     def delete_key(self):
         # delete EC2 Key Pair
@@ -61,11 +52,8 @@ def delete_key(self):
             self._ec2.delete_key_pair(KeyName=self._key_name)
 
         # delete the key file
-        if os.path.isfile(self._new_key_path):
-            os.unlink(self._new_key_path)
-
-        if os.path.isfile(self._old_key_path):
-            os.unlink(self._old_key_path)
+        if os.path.isfile(self.key_path):
+            os.unlink(self.key_path)
 
     def _ec2_key_exists(self):
         res = self._ec2.describe_key_pairs(Filters=[{'Name': 'key-name', 'Values': [self._key_name]}])
diff --git a/spotty/providers/aws/resources/__init__.py b/spotty/providers/aws/resources/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/spotty/providers/aws/resources/bucket.py b/spotty/providers/aws/resources/bucket.py
new file mode 100644
index 0000000..53c3c22
--- /dev/null
+++ b/spotty/providers/aws/resources/bucket.py
@@ -0,0 +1,11 @@
+from spotty.deployment.abstract_cloud_instance.resources.abstract_bucket import AbstractBucket
+
+
+class Bucket(AbstractBucket):
+
+    def __init__(self, data: dict):
+        self._data = data
+
+    @property
+    def name(self) -> str:
+        return self._data['Name']
diff --git a/spotty/providers/aws/aws_resources/image.py b/spotty/providers/aws/resources/image.py
similarity index 100%
rename from spotty/providers/aws/aws_resources/image.py
rename to spotty/providers/aws/resources/image.py
diff --git a/spotty/providers/aws/resources/instance.py b/spotty/providers/aws/resources/instance.py
new file mode 100644
index 0000000..6f902e6
--- /dev/null
+++ b/spotty/providers/aws/resources/instance.py
@@ -0,0 +1,84 @@
+from datetime import datetime
+from spotty.deployment.abstract_cloud_instance.resources.abstract_instance import AbstractInstance
+from spotty.providers.aws.helpers.instance_prices import get_current_spot_price, get_on_demand_price
+
+
+class Instance(AbstractInstance):
+
+    def __init__(self, ec2, data: dict):
+        self._ec2 = ec2
+        self._data = data
+
+    @staticmethod
+    def get_by_stack_name(ec2, stack_name):
+        """Returns the running instance by its stack name
+           or None if the instance is not running.
+        """
+        res = ec2.describe_instances(Filters=[
+            {'Name': 'tag:aws:cloudformation:stack-name', 'Values': [stack_name]},
+            {'Name': 'instance-state-name', 'Values': ['running']},
+        ])
+
+        if len(res['Reservations']) > 1:
+            raise ValueError('Several running instances for the stack "%s" are found.' % stack_name)
+
+        if not len(res['Reservations']):
+            return None
+
+        return Instance(ec2, res['Reservations'][0]['Instances'][0])
+
+    @property
+    def instance_id(self):
+        return self._data['InstanceId']
+
+    @property
+    def public_ip_address(self) -> str:
+        return self._data.get('PublicIpAddress', None)
+
+    @property
+    def state(self) -> str:
+        return self._data['State']['Name']
+
+    @property
+    def instance_type(self) -> str:
+        return self._data['InstanceType']
+
+    @property
+    def availability_zone(self) -> str:
+        return self._data['Placement']['AvailabilityZone']
+
+    @property
+    def launch_time(self) -> datetime:
+        return self._data['LaunchTime']
+
+    @property
+    def lifecycle(self) -> str:
+        return self._data.get('InstanceLifecycle')
+
+    @property
+    def is_running(self):
+        return self.state == 'running'
+
+    @property
+    def is_stopped(self):
+        return self.state == 'stopped'
+
+    def get_spot_price(self):
+        """Get current Spot Instance price for this instance."""
+        return get_current_spot_price(self._ec2, self.instance_type, self.availability_zone)
+
+    def get_on_demand_price(self):
+        """Get On-demand Instance price for the same instance in the us-east-1 region."""
+        return get_on_demand_price(self.instance_type, 'us-east-1')
+
+    def terminate(self, wait: bool = True):
+        self._ec2.terminate_instances(InstanceIds=[self.instance_id])
+        if wait:
+            waiter = self._ec2.get_waiter('instance_terminated')
+            waiter.wait(InstanceIds=[self.instance_id])
+
+    def stop(self, wait: bool = True):
+        self._ec2.stop_instances(InstanceIds=[self.instance_id])
+        if wait:
+            waiter = self._ec2.get_waiter('instance_stopped')
+            waiter.wait(InstanceIds=[self.instance_id])
diff --git a/spotty/providers/aws/aws_resources/snapshot.py b/spotty/providers/aws/resources/snapshot.py
similarity index 100%
rename from spotty/providers/aws/aws_resources/snapshot.py
rename to spotty/providers/aws/resources/snapshot.py
diff --git a/spotty/providers/aws/resources/stack.py b/spotty/providers/aws/resources/stack.py
new file mode 100644
index 0000000..4f65ce6
--- /dev/null
+++ b/spotty/providers/aws/resources/stack.py
@@ -0,0 +1,137 @@
+from collections import namedtuple
+from time import sleep
+from typing import List, Dict
+from botocore.exceptions import EndpointConnectionError, ClientError
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+
+
+Task = namedtuple('Task', ['message', 'start_resource', 'finish_resource', 'enabled'])
+
+
+class Stack(object):
+
+    def __init__(self, cf, stack_info):
+        self._cf = cf
+        self._stack_info = stack_info
+
+    @staticmethod
+    def get_by_name(cf, stack_name: str):
+        """Returns a Stack by its name."""
+        try:
+            res = cf.describe_stacks(StackName=stack_name)
+        except ClientError as e:
+            # ignore an exception if it raised because the stack doesn't exist
+            error_code = e.response.get('Error', {}).get('Code')
+            if error_code != 'ValidationError':
+                raise e
+
+            res = {'Stacks': []}
+
+        if not len(res['Stacks']):
+            return None
+
+        return Stack(cf, res['Stacks'][0])
+
+    @staticmethod
+    def create_stack(cf, *args, **kwargs):
+        res = cf.create_stack(*args, **kwargs)
+        return Stack(cf, res)
+
+    @staticmethod
+    def update_stack(cf, *args, **kwargs):
+        res = cf.update_stack(*args, **kwargs)
+        return Stack(cf, res)
+
+    @property
+    def stack_id(self) -> str:
+        return self._stack_info['StackId']
+
+    @property
+    def stack_uuid(self) -> str:
+        return self.stack_id.rsplit('/', 1)[-1]
+
+    @property
+    def name(self) -> str:
+        return self._stack_info['StackName']
+
+    @property
+    def status(self) -> str:
+        return self._stack_info['StackStatus']
+
+    @property
+    def outputs(self) -> str:
+        return self._stack_info['Outputs']
+
+    def delete(self):
+        return self._cf.delete_stack(StackName=self.stack_id)
+
+    def wait_stack_created(self, delay=30):
+        waiter = self._cf.get_waiter('stack_create_complete')
+        waiter.wait(StackName=self.stack_id, WaiterConfig={'Delay': delay})
+
+    def wait_stack_updated(self, delay=30):
+        waiter = self._cf.get_waiter('stack_update_complete')
+        waiter.wait(StackName=self.stack_id, WaiterConfig={'Delay': delay})
+
+    def wait_stack_deleted(self, delay=30):
+        waiter = self._cf.get_waiter('stack_delete_complete')
+        waiter.wait(StackName=self.stack_id, WaiterConfig={'Delay': delay})
+
+    def wait_status_changed(self, stack_waiting_status: str, output: AbstractOutputWriter, delay: int = 5):
+        stack = None
+        while True:
+            # get the latest status of the stack
+            try:
+                stack = self.get_by_name(self._cf, self.stack_id)
+            except EndpointConnectionError as e:
+                output.write(str(e))
+                continue
+
+            if stack.status != stack_waiting_status:
+                break
+
+            sleep(delay)
+
+        return stack
+
+    def wait_tasks(self, tasks: List[Task], resource_success_status: str, resource_fail_status: str,
+                   output: AbstractOutputWriter, delay: int = 5):
+        resource_statuses = self._get_resource_statuses()
+
+        for task in tasks:
+            if not task.enabled:
+                continue
+
+            task_started = task_finished = False
+            while not task_finished:
+                start_status = resource_statuses.get(task.start_resource)
+                finish_status = resource_statuses.get(task.finish_resource)
+
+                if not task_started and (not task.start_resource or (start_status == resource_success_status)):
+                    task_started = True
+                    output.write('- %s... ' % task.message, newline=False)
+                elif task_started and (finish_status == resource_success_status):
+                    task_finished = True
+                    output.write('DONE')
+                else:
+                    sleep(delay)
+                    try:
+                        resource_statuses = self._get_resource_statuses()
+                    except EndpointConnectionError as e:
+                        output.write(str(e))
+                        continue
+
+                    # check that the stack is not failed
+                    for status in resource_statuses.values():
+                        if status == resource_fail_status:
+                            if task_started and not task_finished:
+                                output.write('')
+                            return
+
+    def _get_resource_statuses(self) -> Dict[str, str]:
+        stack_resources = self._cf.list_stack_resources(StackName=self.stack_id)
+
+        resource_statuses = {row['LogicalResourceId']: row['ResourceStatus']
+                             for row in stack_resources['StackResourceSummaries']}
+
+        return resource_statuses
diff --git a/spotty/providers/aws/aws_resources/subnet.py b/spotty/providers/aws/resources/subnet.py
similarity index 100%
rename from spotty/providers/aws/aws_resources/subnet.py
rename to spotty/providers/aws/resources/subnet.py
diff --git a/spotty/providers/aws/aws_resources/volume.py b/spotty/providers/aws/resources/volume.py
similarity index 96%
rename from spotty/providers/aws/aws_resources/volume.py
rename to spotty/providers/aws/resources/volume.py
index e510924..285e5a6 100644
--- a/spotty/providers/aws/aws_resources/volume.py
+++ b/spotty/providers/aws/resources/volume.py
@@ -1,4 +1,4 @@
-from spotty.providers.aws.aws_resources.snapshot import Snapshot
+from spotty.providers.aws.resources.snapshot import Snapshot
 
 
 class Volume(object):
diff --git a/spotty/providers/aws/aws_resources/vpc.py b/spotty/providers/aws/resources/vpc.py
similarity index 100%
rename from spotty/providers/aws/aws_resources/vpc.py
rename to spotty/providers/aws/resources/vpc.py
diff --git a/spotty/providers/gcp/commands/create_image.py b/spotty/providers/gcp/commands/create_image.py
deleted file mode 100644
index 8764d84..0000000
--- a/spotty/providers/gcp/commands/create_image.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import subprocess
-from argparse import Namespace, ArgumentParser
-from spotty.commands.abstract_config_command import AbstractConfigCommand
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.helpers.ssh import get_ssh_command
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
-from spotty.providers.gcp.instance_manager import InstanceManager
-
-
-class CreateImageCommand(AbstractConfigCommand):
-
-    name = 'create-image'
-    description = 'Create an image with NVIDIA Docker'
-
-    def configure(self, parser: ArgumentParser):
-        super().configure(parser)
-        parser.add_argument('-f', '--family-name', type=str, default=None, help='Set an image family')
-        parser.add_argument('--debug-mode', action='store_true', help='Don\'t terminate the instance on failure')
-
-    def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
-        # check that it's a GCP instance
-        if not isinstance(instance_manager, InstanceManager):
-            raise ValueError('Instance "%s" is not an GCP instance.' % instance_manager.instance_config.name)
-
-        deployment = instance_manager.image_deployment
-
-        try:
-            deployment.deploy(args.family_name, args.debug_mode, output)
-        finally:
-            if args.debug_mode:
-                ip_address = deployment.get_ip_address()
-                if ip_address:
-                    ssh_command = get_ssh_command(ip_address, instance_manager.ssh_port, instance_manager.ssh_user,
-                                                  instance_manager.ssh_key_path, 'tmux')
-
-                    output.write('\nUse the following command to connect to the instance:\n'
-                                 '  %s\n' % subprocess.list2cmdline(ssh_command))
diff --git a/spotty/providers/gcp/commands/delete_image.py b/spotty/providers/gcp/commands/delete_image.py
deleted file mode 100644
index ef88af0..0000000
--- a/spotty/providers/gcp/commands/delete_image.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from argparse import Namespace
-from spotty.commands.abstract_config_command import AbstractConfigCommand
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
-from spotty.providers.gcp.instance_manager import InstanceManager
-
-
-class DeleteImageCommand(AbstractConfigCommand):
-
-    name = 'delete-image'
-    description = 'Delete an image with NVIDIA Docker'
-
-    def _run(self, instance_manager: AbstractInstanceManager, args: Namespace, output: AbstractOutputWriter):
-        # check that it's an AWS instance
-        if not isinstance(instance_manager, InstanceManager):
-            raise ValueError('Instance "%s" is not an AWS instance.' % instance_manager.instance_config.name)
-
-        instance_manager.image_deployment.delete(output)
diff --git a/spotty/providers/gcp/deployment/project_resources/disk_volume.py b/spotty/providers/gcp/config/disk_volume.py
similarity index 56%
rename from spotty/providers/gcp/deployment/project_resources/disk_volume.py
rename to spotty/providers/gcp/config/disk_volume.py
index 89d3c44..ea62a50 100644
--- a/spotty/providers/gcp/deployment/project_resources/disk_volume.py
+++ b/spotty/providers/gcp/config/disk_volume.py
@@ -1,33 +1,23 @@
-from spotty.deployment.abstract_instance_volume import AbstractInstanceVolume
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
 from spotty.providers.gcp.config.validation import validate_disk_volume_parameters
-from spotty.providers.gcp.gcp_resources.disk import Disk
-from spotty.providers.gcp.gcp_resources.snapshot import Snapshot
-from spotty.providers.gcp.helpers.ce_client import CEClient
 
 
 class DiskVolume(AbstractInstanceVolume):
 
-    DP_CREATE_SNAPSHOT = 'create_snapshot'
-    DP_UPDATE_SNAPSHOT = 'update_snapshot'
-    DP_RETAIN = 'retain'
-    DP_DELETE = 'delete'
+    TYPE_NAME = 'Disk'
 
-    def __init__(self, ce: CEClient, volume_config: dict, project_name: str, instance_name: str):
-        self._ce = ce
-        self._name = volume_config['name']
-        self._params = validate_disk_volume_parameters(volume_config['parameters'])
+    DP_CREATE_SNAPSHOT = 'CreateSnapshot'
+    DP_UPDATE_SNAPSHOT = 'UpdateSnapshot'
+    DP_RETAIN = 'Retain'
+    DP_DELETE = 'Delete'
 
-        self._project_name = project_name
-        self._instance_name = instance_name
+    def _validate_volume_parameters(self, params: dict) -> dict:
+        return validate_disk_volume_parameters(params)
 
     @property
     def title(self):
         return 'Disk'
 
-    @property
-    def name(self):
-        return self._name
-
     @property
     def size(self) -> int:
         return self._params['size']
@@ -56,6 +46,7 @@ def disk_name(self) -> str:
 
     @property
     def mount_dir(self) -> str:
+        """A directory where the volume will be mounted on the host OS."""
         if self._params['mountDir']:
             mount_dir = self._params['mountDir']
         else:
@@ -63,8 +54,7 @@ def mount_dir(self) -> str:
 
         return mount_dir
 
-    def get_disk(self) -> Disk:
-        return Disk.get_by_name(self._ce, self.disk_name)
-
-    def get_snapshot(self) -> Snapshot:
-        return Snapshot.get_by_name(self._ce, self.disk_name)
+    @property
+    def host_path(self) -> str:
+        """A path on the host OS that will be mounted to the container."""
+        return self.mount_dir
diff --git a/spotty/providers/gcp/config/image_url.py b/spotty/providers/gcp/config/image_uri.py
similarity index 60%
rename from spotty/providers/gcp/config/image_url.py
rename to spotty/providers/gcp/config/image_uri.py
index d006927..2faf9b3 100644
--- a/spotty/providers/gcp/config/image_url.py
+++ b/spotty/providers/gcp/config/image_uri.py
@@ -1,16 +1,16 @@
 import re
 
 
-IMAGE_URL_REGEX = '^(?:projects/([a-z](?:[-a-z0-9]*[a-z0-9])?)/)?' \
+IMAGE_URI_REGEX = '^(?:(?:https://compute.googleapis.com/compute/v1/)?projects/([a-z](?:[-a-z0-9]*[a-z0-9])?)/)?' \
                   'global/images/(family/)?([a-z](?:[-a-z0-9]*[a-z0-9])?)$'
 
 
-class ImageUrl(object):
+class ImageUri(object):
 
-    def __init__(self, image_url: str):
-        res = re.match(IMAGE_URL_REGEX, image_url)
+    def __init__(self, image_uri: str):
+        res = re.match(IMAGE_URI_REGEX, image_uri)
         if not res:
-            raise ValueError('Image URL has a wrong format')
+            raise ValueError('Image URI has a wrong format')
 
         self._project_id, self._is_family, self._name = res.groups()
 
diff --git a/spotty/providers/gcp/config/instance_config.py b/spotty/providers/gcp/config/instance_config.py
index 8b90fb4..b7be4c9 100644
--- a/spotty/providers/gcp/config/instance_config.py
+++ b/spotty/providers/gcp/config/instance_config.py
@@ -1,16 +1,38 @@
-from spotty.config.abstract_instance_config import AbstractInstanceConfig
+from typing import List
+from spotty.config.abstract_instance_config import AbstractInstanceConfig, VolumeMount
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.providers.gcp.config.disk_volume import DiskVolume
 from spotty.providers.gcp.config.validation import validate_instance_parameters
 
-VOLUME_TYPE_DISK = 'disk'
+
+VOLUME_TYPE_DISK = 'Disk'
 DEFAULT_IMAGE_NAME = 'spotty'
 
 
 class InstanceConfig(AbstractInstanceConfig):
 
-    def __init__(self, config: dict):
-        super().__init__(config)
+    def _validate_instance_params(self, params: dict) -> dict:
+        return validate_instance_parameters(params)
+
+    def _get_instance_volumes(self) -> List[AbstractInstanceVolume]:
+        volumes = []
+        for volume_config in self._params['volumes']:
+            volume_type = volume_config['type']
+            if volume_type == DiskVolume.TYPE_NAME:
+                volumes.append(DiskVolume(volume_config, self.project_config.project_name, self.name))
+            else:
+                raise ValueError('GCP volume type "%s" not supported.' % volume_type)
+
+        return volumes
+
+    @property
+    def user(self):
+        return 'spotty'
 
-        self._params = validate_instance_parameters(self._params)
+    @property
+    def machine_name(self) -> str:
+        """Name of the Compute Engine instance."""
+        return '%s-%s' % (self.project_config.project_name.lower(), self.name.lower())
 
     @property
     def project_id(self) -> str:
@@ -29,21 +51,25 @@ def gpu(self) -> dict:
         return self._params['gpu']
 
     @property
-    def on_demand(self) -> bool:
-        return self._params['onDemandInstance']
+    def is_preemptible_instance(self) -> bool:
+        return self._params['preemptibleInstance']
 
     @property
     def boot_disk_size(self) -> int:
         return self._params['bootDiskSize']
 
+    @property
+    def ports(self) -> List[int]:
+        return list(set(self._params['ports']))
+
     @property
     def image_name(self) -> str:
-        return self._params['imageName'] if self._params['imageName'] else DEFAULT_IMAGE_NAME
+        return self._params['imageName']
 
     @property
     def has_image_name(self) -> bool:
         return bool(self._params['imageName'])
 
     @property
-    def image_url(self) -> str:
-        return self._params['imageUrl']
+    def image_uri(self) -> str:
+        return self._params['imageUri']
diff --git a/spotty/providers/gcp/config/validation.py b/spotty/providers/gcp/config/validation.py
index 426ecb3..2cbec61 100644
--- a/spotty/providers/gcp/config/validation.py
+++ b/spotty/providers/gcp/config/validation.py
@@ -1,21 +1,22 @@
+import os
 from schema import Schema, Optional, And, Regex, Or, Use
-from spotty.config.validation import validate_config, get_instance_parameters_schema
-from spotty.providers.gcp.config.image_url import IMAGE_URL_REGEX
+from spotty.config.validation import validate_config, get_instance_parameters_schema, has_prefix
+from spotty.providers.gcp.config.image_uri import IMAGE_URI_REGEX
 
 
 def validate_instance_parameters(params: dict):
-    from spotty.providers.gcp.config.instance_config import VOLUME_TYPE_DISK
+    from spotty.providers.gcp.config.disk_volume import DiskVolume
 
     instance_parameters = {
         'zone': And(str, Regex(r'^[a-z0-9-]+$')),
-        'machineType': And(str, And(is_valid_machine_type, error='Invalid instance type.')),
+        'machineType': str,
         Optional('gpu', default=None): {
             'type': str,
             Optional('count', default=1): int,
         },
-        Optional('onDemandInstance', default=False): bool,
+        Optional('preemptibleInstance', default=False): bool,
         Optional('imageName', default=None): And(str, len, Regex(r'^[\w-]+$')),
-        Optional('imageUrl', default=None): And(str, len, Regex(IMAGE_URL_REGEX)),
+        Optional('imageUri', default=None): And(str, len, Regex(IMAGE_URI_REGEX)),
         Optional('bootDiskSize', default=0): And(Or(int, str), Use(str),
                                                  Regex(r'^\d+$', error='Incorrect value for "bootDiskSize".'),
                                                  Use(int),
@@ -23,28 +24,37 @@ def validate_instance_parameters(params: dict):
                                                      error='"rootVolumeSize" should be greater than 0 or should '
                                                            'not be specified.'),
                                                  ),
+        Optional('ports', default=[]): [And(int, lambda x: 0 < x < 65536)],
     }
 
     instance_checks = [
-        And(lambda x: not x['gpu'] or is_gpu_machine_type(x['machineType']),
-            error='GPU cannot be attached to shared-core or memory-optimized machine types.'),
-        And(lambda x: not (x['imageName'] and x['imageUrl']),
-            error='"imageName" and "imageUrl" parameters cannot be used together.'),
+        And(lambda x: not (x['imageName'] and x['imageUri']),
+            error='"imageName" and "imageUri" parameters cannot be used together.'),
     ]
 
-    schema = get_instance_parameters_schema(instance_parameters, VOLUME_TYPE_DISK, instance_checks, [])
+    volume_checks = [
+        And(lambda x: not has_prefix([(volume['parameters']['mountDir'] + '/') for volume in x
+                                      if volume['parameters'].get('mountDir')]),
+            error='Mount directories cannot be prefixes for each other.'),
+    ]
+
+    schema = get_instance_parameters_schema(instance_parameters, DiskVolume.TYPE_NAME, instance_checks, volume_checks)
 
     return validate_config(schema, params)
 
 
 def validate_disk_volume_parameters(params: dict):
-    from spotty.providers.gcp.deployment.project_resources.disk_volume import DiskVolume
+    from spotty.providers.gcp.config.disk_volume import DiskVolume
 
     schema = Schema({
         Optional('diskName', default=''): And(str, Regex(r'^[\w-]{1,255}$')),
-        Optional('mountDir', default=''): str,  # all the checks happened in the base configuration
+        Optional('mountDir', default=''): And(
+            str,
+            And(os.path.isabs, error='Use absolute paths in the "mountDir" parameters'),
+            Use(lambda x: x.rstrip('/'))
+        ),
         Optional('size', default=0): And(int, lambda x: x > 0),
-        Optional('deletionPolicy', default=DiskVolume.DP_CREATE_SNAPSHOT): And(
+        Optional('deletionPolicy', default=DiskVolume.DP_RETAIN): And(
             str,
             lambda x: x in [DiskVolume.DP_CREATE_SNAPSHOT,
                             DiskVolume.DP_UPDATE_SNAPSHOT,
@@ -54,24 +64,3 @@ def validate_disk_volume_parameters(params: dict):
     })
 
     return validate_config(schema, params)
-
-
-def is_valid_machine_type(instance_type: str):
-    return instance_type in [
-        'n1-standard-1', 'n1-standard-2', 'n1-standard-4', 'n1-standard-8', 'n1-standard-16', 'n1-standard-32', 'n1-standard-64', 'n1-standard-96',
-        'n1-highmem-2', 'n1-highmem-4', 'n1-highmem-8', 'n1-highmem-16', 'n1-highmem-32', 'n1-highmem-64', 'n1-highmem-96',
-        'n1-highcpu-2', 'n1-highcpu-4', 'n1-highcpu-8', 'n1-highcpu-16', 'n1-highcpu-32', 'n1-highcpu-64', 'n1-highcpu-96',
-        'f1-micro', 'g1-small',
-        'n1-ultramem-40', 'n1-ultramem-80', 'n1-ultramem-160',
-        'n1-megamem-96',
-    ]
-
-
-def is_gpu_machine_type(instance_type: str):
-    return is_valid_machine_type(instance_type) and instance_type not in [
-        # shared-core machine types
-        'f1-micro', 'g1-small',
-        # memory-optimized machine types
-        'n1-ultramem-40', 'n1-ultramem-80', 'n1-ultramem-160',
-        'n1-megamem-96',
-    ]
diff --git a/spotty/providers/gcp/data_transfer.py b/spotty/providers/gcp/data_transfer.py
new file mode 100644
index 0000000..dcc55f2
--- /dev/null
+++ b/spotty/providers/gcp/data_transfer.py
@@ -0,0 +1,49 @@
+import logging
+import subprocess
+from spotty.deployment.abstract_cloud_instance.abstract_data_transfer import AbstractDataTransfer
+from spotty.providers.gcp.helpers.gsutil_rsync import check_gsutil_installed, get_rsync_command
+
+
+class DataTransfer(AbstractDataTransfer):
+
+    @property
+    def scheme_name(self) -> str:
+        return 'gs'
+
+    def upload_local_to_bucket(self, bucket_name: str, dry_run: bool = False):
+        """Uploads files from local to the bucket."""
+        # check gsutil is installed
+        check_gsutil_installed()
+
+        # sync the project with S3, deleted files will be deleted from S3
+        local_cmd = get_rsync_command(self._local_project_dir, self._get_bucket_project_path(bucket_name),
+                                      filters=self._sync_filters, delete=True, dry_run=dry_run)
+
+        # execute the command locally
+        logging.debug('Local sync command: ' + local_cmd)
+        exit_code = subprocess.call(local_cmd, shell=True)
+        if exit_code != 0:
+            raise ValueError('Failed to upload the project files to the GS bucket.')
+
+    def download_bucket_to_local(self, bucket_name: str, download_filters: list):
+        """Downloads files from the bucket to local."""
+        raise NotImplementedError
+
+    def get_download_bucket_to_instance_command(self, bucket_name: str, use_sudo: bool = False) -> str:
+        """A remote command to download files from the bucket to the instance."""
+        remote_cmd = get_rsync_command(self._get_bucket_project_path(bucket_name), self._host_project_dir,
+                                       filters=self._sync_filters)
+        if use_sudo:
+            remote_cmd = 'sudo ' + remote_cmd
+
+        return remote_cmd
+
+    def get_upload_instance_to_bucket_command(self, bucket_name: str, download_filters: list, use_sudo: bool = False,
+                                              dry_run: bool = False) -> str:
+        """A remote command to upload files from the instance to the bucket.
+
+        It uses a temporary directory on the bucket that is unique for the instance. This
+        directory keeps all downloaded from the instance files to sync only changed
+        files with local.
+        """
+        raise NotImplementedError
diff --git a/spotty/providers/gcp/deployment/abstract_gcp_deployment.py b/spotty/providers/gcp/deployment/abstract_gcp_deployment.py
deleted file mode 100644
index 16b4a0e..0000000
--- a/spotty/providers/gcp/deployment/abstract_gcp_deployment.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from abc import ABC, abstractmethod
-from spotty.providers.gcp.config.instance_config import InstanceConfig
-from spotty.providers.gcp.helpers.gcp_credentials import GcpCredentials
-from spotty.providers.gcp.helpers.ssh_key import SshKey
-from spotty.providers.gcp.helpers.ce_client import CEClient
-
-
-class AbstractGcpDeployment(ABC):
-
-    def __init__(self, project_name: str, instance_config: InstanceConfig):
-        self._project_name = project_name
-        self._instance_config = instance_config
-        self._credentials = GcpCredentials()
-        self._ce = CEClient(self._credentials.project_id, instance_config.zone)
-
-    @property
-    def instance_config(self) -> InstanceConfig:
-        return self._instance_config
-
-    @property
-    @abstractmethod
-    def machine_name(self) -> str:
-        """Name of the Compute Engine instance."""
-        raise NotImplementedError
-
-    @property
-    def ssh_key(self) -> SshKey:
-        return SshKey(self._project_name, self.instance_config.zone, self.instance_config.provider_name)
diff --git a/spotty/providers/gcp/deployment/dm_templates/image/cloud_init.yaml b/spotty/providers/gcp/deployment/dm_templates/image/cloud_init.yaml
deleted file mode 100644
index a4e0c11..0000000
--- a/spotty/providers/gcp/deployment/dm_templates/image/cloud_init.yaml
+++ /dev/null
@@ -1,128 +0,0 @@
-#cloud-config
-
-write_files:
-  - path: /run/spotty-image/scripts/init.sh
-    permissions: 0755
-    owner: root
-    group: root
-    content: |
-      #!/bin/bash -x
-
-      # stop daily updates for apt (fix for the issue when the cloud-init fails to install packages
-      # because the "/var/lib/dpkg/lock" file is locked)
-      systemctl disable --now apt-daily{,-upgrade}.{timer,service}
-
-      # install gcloud
-      export CLOUD_SDK_REPO="cloud-sdk-$(lsb_release -c -s)"
-      echo "deb http://packages.cloud.google.com/apt $CLOUD_SDK_REPO main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list
-      curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
-      apt-get update && sudo apt-get install -y google-cloud-sdk
-
-      # install NVIDIA driver and NVIDIA Docker
-      /bin/bash -xe /run/spotty-image/scripts/prepare_instance.sh
-
-      if [[ $? -eq 0 ]] ; then
-        # send a signal that the instance is ready
-        gcloud beta runtime-config configs variables set /success/1 1 --config-name {{MACHINE_NAME}}-docker-status --is-text
-
-        {{^DEBUG_MODE}}
-        # create an image
-        IMAGE_ID=`/bin/bash -xe /run/spotty-image/scripts/create_image.sh`
-
-        if [[ $? -eq 0 ]] ; then
-          # send a success signal with the image ID (it will be used for the deployment output)
-          gcloud beta runtime-config configs variables set /success/1 $IMAGE_ID --config-name {{MACHINE_NAME}}-image-status --is-text
-        else
-          # something failed during image creation, send a failure signal
-          gcloud beta runtime-config configs variables set /failure/1 1 --config-name {{MACHINE_NAME}}-image-status --is-text
-        fi
-        {{/DEBUG_MODE}}
-      else
-        # something failed during installation, send a failure signal
-        gcloud beta runtime-config configs variables set /failure/1 1 --config-name {{MACHINE_NAME}}-docker-status --is-text
-      fi
-
-      {{^DEBUG_MODE}}
-      # delete the instace
-      gcloud compute instances delete {{MACHINE_NAME}} --zone {{ZONE}} -q
-
-      # shutdown the instance
-      shutdown -h now
-      {{/DEBUG_MODE}}
-
-  - path: /run/spotty-image/scripts/prepare_instance.sh
-    permissions: 0755
-    owner: root
-    group: root
-    content: |
-      #!/bin/bash -xe
-
-      # install Docker CE
-      apt-get update
-      apt-get install -y \
-        apt-transport-https \
-        ca-certificates \
-        curl \
-        gnupg-agent \
-        software-properties-common
-      curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
-      add-apt-repository \
-         "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
-         $(lsb_release -cs) \
-         stable"
-      apt-get update
-      apt-get install -y \
-        docker-ce=5:{{DOCKER_CE_VERSION}}~3-0~ubuntu-xenial \
-        docker-ce-cli=5:{{DOCKER_CE_VERSION}}~3-0~ubuntu-xenial \
-        containerd.io={{CONTAINERD_IO_VERSION}}
-
-      # install NVIDIA driver
-      apt-key adv --fetch-keys  http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1604/x86_64/7fa2af80.pub
-      bash -c 'echo "deb http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1604/x86_64 /" > /etc/apt/sources.list.d/cuda.list'
-      mkdir /usr/lib/nvidia  # a fix to install the driver, see: https://devtalk.nvidia.com/default/topic/1032456/linux/nvidia-387-26-for-ubuntu-16-04-package-broken-/
-      apt-get update
-      apt-get install -y nvidia-{{NVIDIA_DRIVER_VERSION}}
-
-      # install nvidia-docker2
-      curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | apt-key add -
-      distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
-      curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | \
-        tee /etc/apt/sources.list.d/nvidia-docker.list
-      apt-get update
-      apt-get install -y nvidia-container-toolkit={{NVIDIA_CONTAINER_TOOLKIT_VERSION}}
-
-      # reload the Docker daemon configuration
-      systemctl restart docker
-
-      # test NVIDIA Docker runtime
-      docker run --gpus all --rm nvidia/cuda:10.1-base-ubuntu16.04 nvidia-smi
-      docker rmi $(docker images -q)
-
-  - path: /run/spotty-image/scripts/create_image.sh
-    permissions: 0755
-    owner: root
-    group: root
-    content: |
-      #!/bin/bash -xe
-
-      # install jq
-      apt-get install -y jq > /dev/null
-
-      # flush the disk buffers
-      sync
-
-      # create AMI
-      IMAGE_JSON=`gcloud compute images create {{IMAGE_NAME}} \
-          --force \
-          --source-disk {{MACHINE_NAME}} \
-          --source-disk-zone {{ZONE}} \
-          --storage-location eu \
-          {{#IMAGE_FAMILY}}--family {{IMAGE_FAMILY}} {{/IMAGE_FAMILY}}\
-          --description "Spotty Image v{{STACK_VERSION}}: Ubuntu 16.04 LTS, NVIDIA driver {{NVIDIA_DRIVER_VERSION}}, Docker CE {{DOCKER_CE_VERSION}}, NVIDIA Container Toolkit {{NVIDIA_CONTAINER_TOOLKIT_VERSION}}." \
-          --format json`
-
-      # return the image ID
-      echo $IMAGE_JSON | jq -r '.[0].id'
-
-runcmd:
-  - /bin/bash -x /run/spotty-image/scripts/init.sh
diff --git a/spotty/providers/gcp/deployment/dm_templates/image/template.yaml b/spotty/providers/gcp/deployment/dm_templates/image/template.yaml
deleted file mode 100644
index e8f2738..0000000
--- a/spotty/providers/gcp/deployment/dm_templates/image/template.yaml
+++ /dev/null
@@ -1,127 +0,0 @@
-resources:
-  - name: {{MACHINE_NAME}}
-    type: compute.v1.instance
-    properties:
-      zone: {{ZONE}}
-      machineType: zones/{{ZONE}}/machineTypes/{{MACHINE_TYPE}}
-      scheduling:
-        {{#GPU_TYPE}}
-        onHostMaintenance: TERMINATE
-        automaticRestart: false
-        {{/GPU_TYPE}}
-        preemptible: {{PREEMPTIBLE}}
-      serviceAccounts:
-        - email: {{SERVICE_ACCOUNT_EMAIL}}
-          scopes: ['https://www.googleapis.com/auth/cloud-platform']
-      tags:
-        items:
-          - {{MACHINE_NAME}}
-      disks:
-        - deviceName: boot
-          type: PERSISTENT
-          boot: true
-          autoDelete: true
-          initializeParams:
-            diskName: {{MACHINE_NAME}}
-            sourceImage: {{SOURCE_IMAGE}}
-      networkInterfaces:
-        - network: global/networks/default
-          accessConfigs:
-            - name: External NAT
-              type: ONE_TO_ONE_NAT
-      {{#GPU_TYPE}}
-      guestAccelerators:
-        - acceleratorType: zones/{{ZONE}}/acceleratorTypes/{{GPU_TYPE}}
-          acceleratorCount: {{GPU_COUNT}}
-      {{/GPU_TYPE}}
-      metadata:
-        items:
-          {{#PUB_KEY_VALUE}}
-          - key: 'ssh-keys'
-            value: |
-              ubuntu:ssh-rsa {{{PUB_KEY_VALUE}}} ubuntu
-          {{/PUB_KEY_VALUE}}
-          - key: 'user-data'
-            value: |
-              {{{STARTUP_SCRIPT}}}
-
-  - name: {{MACHINE_NAME}}-docker-status
-    type: runtimeconfig.v1beta1.config
-    properties:
-      config: {{MACHINE_NAME}}-docker-status
-      description: Docker status
-
-  {{^DEBUG_MODE}}
-  - name: {{MACHINE_NAME}}-image-status
-    type: runtimeconfig.v1beta1.config
-    properties:
-      config: {{MACHINE_NAME}}-image-status
-      description: Image status
-  {{/DEBUG_MODE}}
-
-  - name: {{MACHINE_NAME}}-docker-waiter
-    type: runtimeconfig.v1beta1.waiter
-    metadata:
-      dependsOn:
-        - {{MACHINE_NAME}}
-    properties:
-      parent: $(ref.{{MACHINE_NAME}}-docker-status.name)
-      waiter: {{MACHINE_NAME}}-docker-waiter
-      timeout: 1800s
-      success:
-        cardinality:
-          path: /success
-          number: 1
-      failure:
-        cardinality:
-          path: /failure
-          number: 1
-
-  {{^DEBUG_MODE}}
-  - name: {{MACHINE_NAME}}-image-waiter
-    type: runtimeconfig.v1beta1.waiter
-    metadata:
-      dependsOn:
-        - {{MACHINE_NAME}}-docker-waiter
-    properties:
-      parent: $(ref.{{MACHINE_NAME}}-image-status.name)
-      waiter: {{MACHINE_NAME}}-image-waiter
-      timeout: 1200s
-      success:
-        cardinality:
-          path: /success
-          number: 1
-      failure:
-        cardinality:
-          path: /failure
-          number: 1
-  {{/DEBUG_MODE}}
-
-  {{^DEBUG_MODE}}
-  # create a variable that will contain an image ID (used in the deployment outputs)
-  - name: {{MACHINE_NAME}}-image-status-success
-    type: runtimeconfig.v1beta1.variable
-    metadata:
-      dependsOn:
-        - {{MACHINE_NAME}}-image-waiter
-    properties:
-      parent: $(ref.{{MACHINE_NAME}}-image-status.name)
-      variable: /success/1
-  {{/DEBUG_MODE}}
-
-  {{^DEBUG_MODE}}
-  # acquire the created image resource to remove it later with the deployment
-  - name: {{IMAGE_NAME}}
-    type: compute.v1.image
-    metadata:
-      dependsOn:
-        - {{MACHINE_NAME}}-image-waiter
-    properties:
-      sourceImage: global/images/{{IMAGE_NAME}}
-  {{/DEBUG_MODE}}
-
-{{^DEBUG_MODE}}
-outputs:
-  - name: imageId
-    value: $(ref.{{MACHINE_NAME}}-image-status-success.text)
-{{/DEBUG_MODE}}
diff --git a/spotty/providers/gcp/deployment/dm_templates/image_template.py b/spotty/providers/gcp/deployment/dm_templates/image_template.py
deleted file mode 100644
index e013214..0000000
--- a/spotty/providers/gcp/deployment/dm_templates/image_template.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import os
-import chevron
-from spotty.providers.gcp.config.instance_config import InstanceConfig
-from spotty.utils import fix_indents_for_lines
-
-
-def prepare_image_template(instance_config: InstanceConfig, machine_name: str, scr_image_link: str, image_family: str,
-                           service_account_email: str, stack_version: str, public_key_value: str = '',
-                           debug_mode: bool = False):
-    """Prepares deployment template to run an instance."""
-
-    # read and update the template
-    with open(os.path.join(os.path.dirname(__file__), 'image', 'template.yaml')) as f:
-        template = f.read()
-
-    # render startup script
-    startup_script = open(os.path.join(os.path.dirname(__file__), 'image', 'cloud_init.yaml'), 'r').read()
-    startup_script = chevron.render(startup_script, {
-        'MACHINE_NAME': machine_name,
-        'ZONE': instance_config.zone,
-        'IMAGE_NAME': instance_config.image_name,
-        'IMAGE_FAMILY': image_family if image_family else '',
-        'STACK_VERSION': stack_version,
-        'NVIDIA_DRIVER_VERSION': '410',
-        'DOCKER_CE_VERSION': '19.03.5',
-        'CONTAINERD_IO_VERSION': '1.2.10-3',
-        'NVIDIA_CONTAINER_TOOLKIT_VERSION': '1.0.5-1',
-        'DEBUG_MODE': debug_mode,
-    })
-
-    # render the template
-    parameters = {
-        'SERVICE_ACCOUNT_EMAIL': service_account_email,
-        'ZONE': instance_config.zone,
-        'MACHINE_TYPE': instance_config.machine_type,
-        'MACHINE_NAME': machine_name,
-        'SOURCE_IMAGE': scr_image_link,
-        'IMAGE_NAME': instance_config.image_name,
-        'STARTUP_SCRIPT': fix_indents_for_lines(startup_script, template, '{{{STARTUP_SCRIPT}}}'),
-        'PREEMPTIBLE': 'false' if instance_config.on_demand else 'true',
-        'GPU_TYPE': instance_config.gpu['type'],
-        'GPU_COUNT': instance_config.gpu['count'],
-        'PUB_KEY_VALUE': public_key_value,
-        'DEBUG_MODE': debug_mode,
-    }
-    template = chevron.render(template, parameters)
-
-    return template
diff --git a/spotty/providers/gcp/deployment/dm_templates/instance/cloud_init.yaml b/spotty/providers/gcp/deployment/dm_templates/instance/cloud_init.yaml
deleted file mode 100644
index 3c05745..0000000
--- a/spotty/providers/gcp/deployment/dm_templates/instance/cloud_init.yaml
+++ /dev/null
@@ -1,163 +0,0 @@
-#cloud-config
-
-write_files:
-  - path: /tmp/spotty/instance/scripts/init.sh
-    permissions: 0755
-    owner: ubuntu
-    content: |
-      #!/bin/bash -x
-
-      # prepare the instance and run Docker container
-      /tmp/spotty/instance/scripts/prepare_instance.sh \
-        && /tmp/spotty/instance/scripts/mount_volumes.sh \
-        && /tmp/spotty/instance/scripts/sync_project.sh {{{SYNC_ARGS}}} \
-        && /tmp/spotty/instance/scripts/startup_commands.sh \
-        && /tmp/spotty/instance/scripts/run_container.sh
-
-      # send signal that the Docker container is ready or failed
-      DOCKER_EXIT_CODE=$?
-      if [[ $DOCKER_EXIT_CODE -eq 0 ]] ; then
-        gcloud beta runtime-config configs variables set /success/1 1 --config-name {{MACHINE_NAME}}-docker-status --is-text
-      else
-        gcloud beta runtime-config configs variables set /failure/1 1 --config-name {{MACHINE_NAME}}-docker-status --is-text
-        exit $DOCKER_EXIT_CODE
-      fi
-
-  - path: /tmp/spotty/instance/scripts/prepare_instance.sh
-    permissions: 0755
-    owner: ubuntu
-    content: |
-      #!/bin/bash -xe
-
-      # install jq
-      apt-get install -y jq
-
-      # create directory for Docker scripts
-      mkdir -p /tmp/spotty/container/scripts/run
-      chmod -R 755 /tmp/spotty
-      chown -R ubuntu:ubuntu /tmp/spotty
-
-      # create directory for Spotty logs
-      mkdir -p /var/log/spotty/run
-      chmod -R 755 /var/log/spotty
-      chown -R ubuntu:ubuntu /var/log/spotty
-
-      # create a project directory
-      if [ -n "${HostProjectDirectory}" ]; then
-        mkdir -p ${HostProjectDirectory}
-      fi
-
-  - path: /tmp/spotty/instance/scripts/mount_volumes.sh
-    permissions: 0755
-    owner: ubuntu
-    content: |
-      #!/bin/bash -xe
-
-      DEVICE_NAMES=({{{DISK_DEVICE_NAMES}}})
-      MOUNT_DIRS=({{{DISK_MOUNT_DIRS}}})
-
-      for i in ${!DEVICE_NAMES[*]}
-      do
-        DEVICE=/dev/disk/by-id/google-${DEVICE_NAMES[$i]}
-        MOUNT_DIR=${MOUNT_DIRS[$i]}
-
-        blkid -o value -s TYPE $DEVICE || mkfs -t ext4 $DEVICE
-        mkdir -p $MOUNT_DIR
-        mount $DEVICE $MOUNT_DIR
-        resize2fs $DEVICE
-      done
-
-  - path: /tmp/spotty/instance/scripts/sync_project.sh
-    permissions: 0755
-    owner: ubuntu
-    content: |
-      #!/bin/bash -e
-
-      if [ -n "{{PROJECT_GS_BUCKET}}" ] && [ -n "{{HOST_PROJECT_DIR}}" ]; then
-        mkdir -p {{HOST_PROJECT_DIR}}
-        gsutil -m rsync -r "$@" gs://{{PROJECT_GS_BUCKET}}/{{BUCKET_SYNC_DIR}} {{HOST_PROJECT_DIR}}
-      fi
-
-  - path: /tmp/spotty/instance/scripts/startup_commands.sh
-    permissions: 0755
-    owner: ubuntu
-    content: |
-      #!/bin/bash -xe
-
-      {{{INSTANCE_STARTUP_COMMANDS}}}
-
-  - path: /tmp/spotty/instance/scripts/run_container.sh
-    permissions: 0755
-    owner: ubuntu
-    content: |
-      #!/bin/bash -xe
-
-      # change docker data root directory
-      if [ -n "{{DOCKER_DATA_ROOT_DIR}}" ]; then
-        jq '. + { "data-root": "{{DOCKER_DATA_ROOT_DIR}}" }' /etc/docker/daemon.json > /tmp/docker_daemon.json \
-          && mv /tmp/docker_daemon.json /etc/docker/daemon.json
-        service docker restart
-
-        # remove all containers
-        if [[ $(docker ps -aq | wc -c) -ne 0 ]]; then
-          docker rm $(docker ps -aq)
-        fi
-      fi
-
-      # build docker image
-      DOCKER_IMAGE="{{DOCKER_IMAGE}}"
-      if [ -n "{{DOCKERFILE_PATH}}" ]; then
-        DOCKER_IMAGE=spotty:`date +%s`
-        docker build -t $DOCKER_IMAGE -f {{DOCKERFILE_PATH}} {{DOCKER_BUILD_CONTEXT_PATH}}
-      fi
-
-      if [ -n "$DOCKER_IMAGE" ]; then
-        # run docker container
-        CONTAINER_NAME=spotty
-        docker run {{DOCKER_RUNTIME_PARAMS}} \
-          -v /root/.config:/root/.config \
-          -v /tmp/spotty/container/scripts:/tmp/scripts \
-          --name $CONTAINER_NAME $DOCKER_IMAGE /bin/sh
-
-        WORKING_DIR=""
-        if [ -n "{{DOCKER_WORKING_DIR}}" ]; then
-          WORKING_DIR="-w {{DOCKER_WORKING_DIR}}"
-
-          # create working directory if it doesn't exist
-          docker exec $CONTAINER_NAME mkdir -p {{DOCKER_WORKING_DIR}}
-        fi
-
-        # create a script to connect to the docker container
-        mkdir -p /scripts
-        cat > /tmp/spotty/instance/scripts/container_bash.sh <<EOL
-      #!/bin/bash
-      docker exec -it $WORKING_DIR $CONTAINER_NAME /bin/bash \$@
-      EOL
-        chmod 755 /tmp/spotty/instance/scripts/container_bash.sh
-        chown ubuntu:ubuntu /tmp/spotty/instance/scripts/container_bash.sh
-
-        # create an alias to connect to the docker container
-        CONTAINER_BASH_ALIAS=container
-        echo "alias $CONTAINER_BASH_ALIAS=\"sudo /tmp/spotty/instance/scripts/container_bash.sh\"" >> /home/ubuntu/.bashrc
-        echo "alias $CONTAINER_BASH_ALIAS=\"/tmp/spotty/instance/scripts/container_bash.sh\"" >> /root/.bashrc
-
-        # run custom user commands
-        docker exec $WORKING_DIR $CONTAINER_NAME /bin/bash -xe /tmp/scripts/startup_commands.sh \
-          > /var/log/spotty/container-startup-commands.log 2>&1
-      fi
-
-  - path: /tmp/spotty/container/scripts/startup_commands.sh
-    permissions: 0644
-    owner: ubuntu
-    content: |
-      # container startup commands
-      {{{CONTAINER_STARTUP_COMMANDS}}}
-
-  - path: /home/ubuntu/.tmux.conf
-    permissions: 0664
-    owner: ubuntu
-    content: |
-      bind-key x kill-pane
-
-runcmd:
-  - /tmp/spotty/instance/scripts/init.sh
diff --git a/spotty/providers/gcp/deployment/dm_templates/instance_template.py b/spotty/providers/gcp/deployment/dm_templates/instance_template.py
deleted file mode 100644
index cf6f7e2..0000000
--- a/spotty/providers/gcp/deployment/dm_templates/instance_template.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import os
-from subprocess import list2cmdline
-from typing import List
-import chevron
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.config.validation import is_subdir
-from spotty.deployment.abstract_instance_volume import AbstractInstanceVolume
-from spotty.deployment.container_deployment import ContainerDeployment
-from spotty.providers.gcp.config.instance_config import InstanceConfig
-from spotty.providers.gcp.deployment.project_resources.disk_volume import DiskVolume
-from spotty.providers.gcp.helpers.sync import BUCKET_SYNC_DIR, get_instance_sync_arguments
-from spotty.utils import fix_indents_for_lines
-
-
-def prepare_instance_template(instance_config: InstanceConfig, container: ContainerDeployment, sync_filters: list,
-                              volumes: List[AbstractInstanceVolume], machine_name: str, image_link: str,
-                              bucket_name: str, public_key_value: str, service_account_email: str,
-                              output: AbstractOutputWriter):
-    """Prepares deployment template to run an instance."""
-
-    # get disk attachments
-    disk_attachments, disk_device_names, disk_mount_dirs = _get_disk_attachments(volumes, instance_config.zone)
-
-    # get Docker runtime parameters
-    runtime_parameters = container.get_runtime_parameters(bool(instance_config.gpu))
-
-    # render startup script
-    with open(os.path.join(os.path.dirname(__file__), 'instance', 'cloud_init.yaml')) as f:
-        startup_script = f.read()
-
-    startup_script = chevron.render(startup_script, {
-        'MACHINE_NAME': machine_name,
-        'ZONE': instance_config.zone,
-        'DISK_DEVICE_NAMES': ('"%s"' % '" "'.join(disk_device_names)) if disk_device_names else '',
-        'DISK_MOUNT_DIRS': ('"%s"' % '" "'.join(disk_mount_dirs)) if disk_mount_dirs else '',
-        'PROJECT_GS_BUCKET': bucket_name,
-        'BUCKET_SYNC_DIR': BUCKET_SYNC_DIR,
-        'HOST_PROJECT_DIR': container.host_project_dir,
-        'SYNC_ARGS': list2cmdline(get_instance_sync_arguments(sync_filters)),
-        'DOCKER_DATA_ROOT_DIR': instance_config.docker_data_root,
-        'DOCKER_IMAGE': container.config.image,
-        'DOCKERFILE_PATH': container.dockerfile_path,
-        'DOCKER_BUILD_CONTEXT_PATH': container.docker_context_path,
-        'DOCKER_RUNTIME_PARAMS': runtime_parameters,
-        'DOCKER_WORKING_DIR': container.config.working_dir,
-        'INSTANCE_STARTUP_COMMANDS': fix_indents_for_lines(instance_config.commands, startup_script,
-                                                           '{{{INSTANCE_STARTUP_COMMANDS}}}'),
-        'CONTAINER_STARTUP_COMMANDS': fix_indents_for_lines(container.config.commands, startup_script,
-                                                            '{{{CONTAINER_STARTUP_COMMANDS}}}'),
-    })
-
-    # render the template
-    with open(os.path.join(os.path.dirname(__file__), 'instance', 'template.yaml')) as f:
-        template = f.read()
-
-    template = chevron.render(template, {
-        'SERVICE_ACCOUNT_EMAIL': service_account_email,
-        'ZONE': instance_config.zone,
-        'MACHINE_TYPE': instance_config.machine_type,
-        'SOURCE_IMAGE': image_link,
-        'BOOT_DISK_SIZE': instance_config.boot_disk_size,
-        'STARTUP_SCRIPT': fix_indents_for_lines(startup_script, template, '{{{STARTUP_SCRIPT}}}'),
-        'MACHINE_NAME': machine_name,
-        'PREEMPTIBLE': 'false' if instance_config.on_demand else 'true',
-        'GPU_TYPE': instance_config.gpu['type'] if instance_config.gpu else '',
-        'GPU_COUNT': instance_config.gpu['count'] if instance_config.gpu else 0,
-        'DISK_ATTACHMENTS': disk_attachments,
-        'PUB_KEY_VALUE': public_key_value,
-        'PORTS': ', '.join([str(port) for port in set(container.config.ports + [22])]),
-    })
-
-    # print some information about the deployment
-    output.write('- image URL: ' + '/'.join(image_link.split('/')[-5:]))
-    output.write('- zone: ' + instance_config.zone)
-    output.write('- on-demand VM' if instance_config.on_demand else '- preemptible VM')
-    output.write(('- GPUs: %d x %s' % (instance_config.gpu['count'], instance_config.gpu['type']))
-                 if instance_config.gpu else '- no GPUs')
-
-    # print name of the volume where Docker data will be stored
-    if instance_config.docker_data_root:
-        docker_data_volume_name = [volume.name for volume in volumes
-                                   if is_subdir(instance_config.docker_data_root, volume.mount_dir)][0]
-        output.write('- Docker data will be stored on the "%s" volume' % docker_data_volume_name)
-
-    return template
-
-
-def _get_disk_attachments(volumes: List[AbstractInstanceVolume], zone: str):
-    disk_attachments = []
-    disk_device_names = []
-    disk_mount_dirs = []
-
-    for i, volume in enumerate(volumes):
-        if isinstance(volume, DiskVolume):
-            device_name = 'disk-%d' % (i + 1)
-            disk_device_names.append(device_name)
-            disk_mount_dirs.append(volume.mount_dir)
-            disk_attachments.append({
-                'DISK_LINK': 'zones/%s/disks/%s' % (zone, volume.disk_name),
-                'DEVICE_NAME': device_name,
-            })
-
-    return disk_attachments, disk_device_names, disk_mount_dirs
diff --git a/spotty/providers/gcp/deployment/image_deployment.py b/spotty/providers/gcp/deployment/image_deployment.py
deleted file mode 100644
index 93ba63e..0000000
--- a/spotty/providers/gcp/deployment/image_deployment.py
+++ /dev/null
@@ -1,81 +0,0 @@
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.gcp.deployment.abstract_gcp_deployment import AbstractGcpDeployment
-from spotty.providers.gcp.deployment.project_resources.image_stack import ImageStackResource
-from spotty.providers.gcp.gcp_resources.image import Image
-from spotty.providers.gcp.gcp_resources.instance import Instance
-from spotty.providers.gcp.helpers.deployment import check_gpu_configuration
-
-
-class ImageDeployment(AbstractGcpDeployment):
-
-    # version of the image stack
-    VERSION = '1.1.0'
-
-    @property
-    def machine_name(self) -> str:
-        return 'spotty-image-%s' % self.instance_config.image_name.lower()
-
-    @property
-    def stack(self):
-        return ImageStackResource(self.instance_config.image_name, self._credentials.project_id, self.instance_config.zone)
-
-    def deploy(self, image_family: str, debug_mode: bool, output: AbstractOutputWriter):
-        # check that the "imageUrl" parameter is not set
-        if self.instance_config.image_url:
-            raise ValueError('The "imageUrl" parameter cannot be used when creating an image.')
-
-        # check that it's an instance with GPU
-        if not self.instance_config.gpu:
-            raise ValueError('Instance with GPU is required to create an image with NVIDIA Docker')
-
-        # check GPU configuration
-        check_gpu_configuration(self._ce, self.instance_config.gpu)
-
-        # check that an image with this name doesn't exist yet
-        image = Image.get_by_name(self._ce, self.instance_config.image_name)
-        if image:
-            raise ValueError('Image with the name "%s" already exists.' % image.name)
-
-        # get the latest Ubuntu 16.04 image
-        ubuntu_family_name = 'projects/ubuntu-os-cloud/global/images/family/ubuntu-1604-lts'
-        image = Image.get_by_url(self._ce, ubuntu_family_name)
-
-        # get or create an SSH key
-        public_key_value = self.ssh_key.get_public_key_value() if debug_mode else ''
-
-        # prepare deployment template
-        from spotty.providers.gcp.deployment.dm_templates.image_template import prepare_image_template
-        template = prepare_image_template(self.instance_config, self.machine_name, image.self_link, image_family,
-                                          self._credentials.service_account_email, self.VERSION,
-                                          public_key_value=public_key_value, debug_mode=debug_mode)
-
-        # create stack
-        self.stack.create_stack(template, self.machine_name, debug_mode, output)
-
-    def delete(self, output: AbstractOutputWriter):
-        # check that the "imageUrl" parameter is not set
-        if self.instance_config.image_url:
-            raise ValueError('The "imageUrl" parameter cannot be used for deleting an image.')
-
-        # check if the image stack exists
-        if not self.stack.get_stack():
-            raise ValueError('Deployment for the "%s" image not found.' % self.instance_config.image_name)
-
-        # ask user to confirm the deletion
-        confirm = input('Image "%s" will be deleted.\n'
-                        'Type "y" to confirm: ' % self.instance_config.image_name)
-        if confirm != 'y':
-            output.write('You didn\'t confirm the operation.')
-            return
-
-        self.stack.delete_stack(output)
-
-    def get_ip_address(self):
-        instance = Instance.get_by_name(self._ce, self.machine_name)
-        if not instance or not instance.is_running:
-            return None
-
-        if self._instance_config.local_ssh_port:
-            return '127.0.0.1'
-
-        return instance.public_ip_address
diff --git a/spotty/providers/gcp/deployment/instance_deployment.py b/spotty/providers/gcp/deployment/instance_deployment.py
deleted file mode 100644
index 5fb0e5f..0000000
--- a/spotty/providers/gcp/deployment/instance_deployment.py
+++ /dev/null
@@ -1,178 +0,0 @@
-from typing import List
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.config.project_config import ProjectConfig
-from spotty.deployment.abstract_instance_volume import AbstractInstanceVolume
-from spotty.helpers.print_info import render_volumes_info_table
-from spotty.providers.gcp.config.instance_config import VOLUME_TYPE_DISK
-from spotty.deployment.container_deployment import ContainerDeployment
-from spotty.providers.gcp.deployment.abstract_gcp_deployment import AbstractGcpDeployment
-from spotty.providers.gcp.deployment.project_resources.bucket import BucketResource
-from spotty.providers.gcp.deployment.project_resources.disk_volume import DiskVolume
-from spotty.providers.gcp.deployment.dm_templates.instance_template import prepare_instance_template
-from spotty.providers.gcp.deployment.project_resources.instance_stack import InstanceStackResource
-from spotty.providers.gcp.gcp_resources.image import Image
-from spotty.providers.gcp.gcp_resources.instance import Instance
-from spotty.providers.gcp.helpers.deployment import check_gpu_configuration
-from spotty.providers.gcp.helpers.sync import sync_local_to_bucket
-
-
-class InstanceDeployment(AbstractGcpDeployment):
-
-    @property
-    def machine_name(self) -> str:
-        """Name of the Compute Engine instance."""
-        return '%s-%s' % (self._project_name.lower(), self.instance_config.name.lower())
-
-    @property
-    def bucket(self) -> BucketResource:
-        region = '-'.join(self.instance_config.zone.split('-')[:-1])
-        return BucketResource(self._project_name, region)
-
-    @property
-    def stack(self) -> InstanceStackResource:
-        return InstanceStackResource(self.machine_name, self._credentials.project_id, self.instance_config.zone)
-
-    def get_instance(self):
-        return Instance.get_by_name(self._ce, self.machine_name)
-
-    def deploy(self, project_config: ProjectConfig, output: AbstractOutputWriter, dry_run=False):
-        # remove the stack it it exists to make all the disks available
-        stack = self.stack
-        stack.delete_stack(output=output)
-
-        # create or get existing bucket for the project
-        bucket_name = self.bucket.get_or_create_bucket(output, dry_run)
-
-        # sync the project with the bucket
-        output.write('Syncing the project with the bucket...')
-        sync_local_to_bucket(project_config.project_dir, bucket_name, project_config.sync_filters, dry_run)
-
-        # check GPU configuration
-        check_gpu_configuration(self._ce, self.instance_config.gpu)
-
-        # get volumes
-        volumes = self._get_volumes()
-        if volumes:
-            # create disks
-            output.write('\nCreating disks...')
-            with output.prefix('  '):
-                self._create_disks(volumes, output=output, dry_run=dry_run)
-            output.write('')
-
-        # prepare Deployment Manager template
-        output.write('Preparing the deployment template...')
-        with output.prefix('  '):
-            # get an image
-            image = self._get_image()
-
-            # get or create an SSH key
-            public_key_value = self.ssh_key.get_public_key_value()
-
-            container = ContainerDeployment(project_config.project_name, volumes, project_config.container)
-            template = prepare_instance_template(self.instance_config, container, project_config.sync_filters, volumes,
-                                                 self.machine_name, image.self_link, bucket_name, public_key_value,
-                                                 self._credentials.service_account_email, output)
-        output.write('')
-
-        # print information about the volumes
-        output.write('Volumes:\n%s\n' % render_volumes_info_table(container.volume_mounts, volumes))
-
-        # create stack
-        if not dry_run:
-            stack.create_stack(template, output=output)
-
-    def delete(self, output: AbstractOutputWriter):
-        self.stack.delete_stack(output)
-
-        # TODO: apply deletion policies
-
-    def _get_volumes(self) -> List[AbstractInstanceVolume]:
-        volumes = []
-        for volume_config in self.instance_config.volumes:
-            volume_type = volume_config['type']
-            if volume_type == VOLUME_TYPE_DISK:
-                volumes.append(DiskVolume(self._ce, volume_config, self._project_name, self.instance_config.name))
-            else:
-                raise ValueError('GCP volume type "%s" not supported.' % volume_type)
-
-        return volumes
-
-    def _create_disks(self, volumes: List[AbstractInstanceVolume], output: AbstractOutputWriter, dry_run: bool = False):
-        disks_to_create = []
-
-        # do some checks and prepare disk parameters
-        for i, volume in enumerate(volumes):
-            if isinstance(volume, DiskVolume):
-                # check if the disk already exists
-                disk = volume.get_disk()
-                if disk:
-                    # check if the volume is available
-                    if not disk.is_available():
-                        raise ValueError('Disk "%s" is not available (status: %s).'
-                                         % (volume.disk_name, disk.status))
-
-                    # check size of the volume
-                    if volume.size and (volume.size != disk.size):
-                        raise ValueError('Specified size for the "%s" volume (%dGB) doesn\'t match the size of the '
-                                         'existing disk (%dGB).' % (volume.name, volume.size, disk.size))
-
-                    output.write('- disk "%s" will be attached' % disk.name)
-                else:
-                    # check if the snapshot exists
-                    snapshot = volume.get_snapshot()
-                    if snapshot:
-                        # disk will be restored from the snapshot
-                        # check size of the volume
-                        if volume.size and (volume.size < snapshot.size):
-                            raise ValueError('Specified size for the "%s" volume (%dGB) is less than size of the '
-                                             'snapshot (%dGB).'
-                                             % (volume.name, volume.size, snapshot.size))
-
-                        output.write('- disk "%s" will be restored from the snapshot' % volume.disk_name)
-
-                        disks_to_create.append((volume.disk_name, volume.size, snapshot.self_link))
-                    else:
-                        # empty volume will be created, check that the size is specified
-                        if not volume.size:
-                            raise ValueError('Size for the new disk is required.')
-
-                        if volume.size < 10:
-                            raise ValueError('Size of a disk cannot be less than 10GB.')
-
-                        disks_to_create.append((volume.disk_name, volume.size, None))
-
-        # create disks
-        for disk_name, disk_size, snapshot_link in disks_to_create:
-            if not dry_run:
-                self._ce.create_disk(disk_name, disk_size, snapshot_link)
-
-            output.write('- disk "%s" was created' % disk_name)
-
-    def _get_image(self) -> Image:
-        """Returns an image that should be used for deployment.
-
-        Raises:
-            ValueError: If an image not found.
-        """
-        if self.instance_config.image_url:
-            # get an image by its URL if the "imageUrl" parameter is specified
-            image = Image.get_by_url(self._ce, self.instance_config.image_url)
-            if not image:
-                raise ValueError('Image "%s" not found.' % self.instance_config.image_url)
-        else:
-            # try to get an image by its name (if the "imageName" parameter is not specified, the default value is used)
-            image = Image.get_by_name(self._ce, self.instance_config.image_name)
-            if not image:
-                if self.instance_config.has_image_name:
-                    # if an image name was explicitly specified, but the image was not found, raise an error
-                    raise ValueError('Image with the name "%s" was not found.' % self.instance_config.image_name)
-                else:
-                    # get the latest image from the "spotty-cloud" project
-                    spotty_image_family_url = 'projects/spotty-cloud/global/images/family/spotty-1-2-5'
-                    image = Image.get_by_url(self._ce, spotty_image_family_url)
-                    if not image:
-                        raise ValueError('Image "%s" not found.\n'
-                                         'Use the "spotty gcp create-image" command to create an image with NVIDIA '
-                                         'Docker.' % spotty_image_family_url)
-
-        return image
diff --git a/spotty/providers/gcp/deployment/project_resources/bucket.py b/spotty/providers/gcp/deployment/project_resources/bucket.py
deleted file mode 100644
index bd0ebd5..0000000
--- a/spotty/providers/gcp/deployment/project_resources/bucket.py
+++ /dev/null
@@ -1,38 +0,0 @@
-import re
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.gcp.helpers.gs_client import GSClient
-from spotty.providers.gcp.helpers.sync import BUCKET_SYNC_DIR
-from spotty.utils import random_string
-
-
-class BucketResource(object):
-
-    def __init__(self, project_name: str, region: str):
-        self._gs = GSClient()
-        self._region = region
-        self._bucket_prefix = 'spotty-%s' % project_name.lower()
-
-    def _find_bucket(self):
-        buckets = self._gs.list_buckets()
-
-        regex = re.compile('-'.join([self._bucket_prefix, '[a-z0-9]{12}', self._region]))
-        bucket_names = [bucket.name for bucket in buckets if regex.match(bucket.name) is not None]
-
-        if len(bucket_names) > 1:
-            raise ValueError('Found several project buckets in the same region: %s.' % ', '.join(bucket_names))
-
-        bucket_name = bucket_names[0] if len(bucket_names) else False
-
-        return bucket_name
-
-    def get_or_create_bucket(self, output: AbstractOutputWriter, dry_run=False):
-        bucket_name = self._find_bucket()
-        if not bucket_name:
-            bucket_name = '-'.join([self._bucket_prefix, random_string(12), self._region])
-            if not dry_run:
-                self._gs.create_bucket(bucket_name, self._region)
-                self._gs.create_dir(bucket_name, BUCKET_SYNC_DIR)
-
-            output.write('Bucket "%s" was created.' % bucket_name)
-
-        return bucket_name
diff --git a/spotty/providers/gcp/deployment/project_resources/image_stack.py b/spotty/providers/gcp/deployment/project_resources/image_stack.py
deleted file mode 100644
index c8d948a..0000000
--- a/spotty/providers/gcp/deployment/project_resources/image_stack.py
+++ /dev/null
@@ -1,74 +0,0 @@
-from collections import OrderedDict
-from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.gcp.gcp_resources.stack import Stack
-from spotty.providers.gcp.helpers.ce_client import CEClient
-from spotty.providers.gcp.helpers.deployment import wait_resources
-from spotty.providers.gcp.helpers.dm_client import DMClient
-
-
-class ImageStackResource(object):
-
-    def __init__(self, image_name: str, project_id: str, zone: str):
-        self._dm = DMClient(project_id, zone)
-        self._ce = CEClient(project_id, zone)
-        self._image_name = image_name
-        self._stack_name = 'spotty-image-%s' % image_name
-
-    def get_stack(self):
-        return Stack.get_by_name(self._dm, self._stack_name)
-
-    def create_stack(self, template: str, machine_name: str, debug_mode: bool, output: AbstractOutputWriter):
-        """Creates an image stack and waits for the image to be created."""
-
-        # check that the stack doesn't exist
-        if self.get_stack():
-            raise ValueError('Deployment "%s" already exists.' % self._stack_name)
-
-        # create stack
-        Stack.create(self._dm, self._stack_name, template)
-
-        output.write('Waiting for the image to be created...')
-
-        resource_messages = OrderedDict([
-            (machine_name, 'launching the instance'),
-            ('%s-docker-waiter' % machine_name, 'installing NVIDIA Docker'),
-        ])
-
-        if not debug_mode:
-            resource_messages['%s-image-waiter' % machine_name] = 'creating an image and terminating the instance'
-
-        # wait for the stack to be created
-        with output.prefix('  '):
-            wait_resources(self._dm, self._ce, self._stack_name, resource_messages,
-                           instance_resource_name=machine_name, machine_name=machine_name, output=output)
-
-        if debug_mode:
-            output.write('Stack "%s" was created in debug mode.' % self._stack_name)
-        else:
-            output.write('\n'
-                         '--------------------------------------------------\n'
-                         'Image "%s" was successfully created.\n'
-                         'Use the "spotty start" command to run an instance.\n'
-                         '--------------------------------------------------'
-                         % self._image_name)
-
-    def delete_stack(self, output: AbstractOutputWriter):
-        # check that the stack exist
-        stack = self.get_stack()
-        if not stack:
-            raise ValueError('Deployment "%s" not found.' % self._stack_name)
-
-        output.write('Waiting for the deployment to be deleted...')
-
-        # delete the stack
-        try:
-            stack.delete()
-            stack.wait_stack_deleted()
-        except Exception as e:
-            raise ValueError('Deployment "%s" was not deleted. Error: %s\n'
-                             'See Deployment Manager logs for details.' % (self._stack_name, str(e)))
-
-        output.write('\n'
-                     '-------------------------------\n'
-                     'Image was successfully deleted.\n'
-                     '-------------------------------')
diff --git a/spotty/providers/gcp/dm_templates/__init__.py b/spotty/providers/gcp/dm_templates/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/spotty/providers/gcp/dm_templates/instance/__init__.py b/spotty/providers/gcp/dm_templates/instance/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/spotty/providers/gcp/dm_templates/instance/data/startup_script.sh.tpl b/spotty/providers/gcp/dm_templates/instance/data/startup_script.sh.tpl
new file mode 100644
index 0000000..35edc30
--- /dev/null
+++ b/spotty/providers/gcp/dm_templates/instance/data/startup_script.sh.tpl
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+set -x
+
+mkdir -p "{{INSTANCE_STARTUP_SCRIPTS_DIR}}"
+
+# create startup scripts
+{{#STARTUP_SCRIPTS}}
+cat <<'EOF' > {{INSTANCE_STARTUP_SCRIPTS_DIR}}/{{filename}}
+{{{content}}}
+EOF
+chmod +x {{INSTANCE_STARTUP_SCRIPTS_DIR}}/{{filename}}
+
+{{/STARTUP_SCRIPTS}}
+
+# run startup scripts
+{{#STARTUP_SCRIPTS}}
+{{INSTANCE_STARTUP_SCRIPTS_DIR}}/{{filename}} && \
+{{/STARTUP_SCRIPTS}}
+true
+
+# send signal that the Docker container is ready or failed
+EXIT_CODE=$?
+if [ $EXIT_CODE -eq 0 ]; then
+  gcloud beta runtime-config configs variables set /success/1 1 --config-name {{MACHINE_NAME}}-docker-status --is-text
+else
+  gcloud beta runtime-config configs variables set /failure/1 1 --config-name {{MACHINE_NAME}}-docker-status --is-text
+  exit $EXIT_CODE
+fi
diff --git a/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/01_prepare_instance.sh b/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/01_prepare_instance.sh
new file mode 100644
index 0000000..3998a38
--- /dev/null
+++ b/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/01_prepare_instance.sh
@@ -0,0 +1,29 @@
+#!/bin/bash -xe
+
+# install jq
+apt-get install -y jq
+
+# create tmux config
+echo "bind-key x kill-pane" > /home/{{SSH_USERNAME}}/.tmux.conf
+
+# create the "container bash" script
+mkdir -p "$(dirname '{{CONTAINER_BASH_SCRIPT_PATH}}')"
+cat > "{{CONTAINER_BASH_SCRIPT_PATH}}" <<'EOF2'
+{{{CONTAINER_BASH_SCRIPT}}}
+EOF2
+chmod +x "{{CONTAINER_BASH_SCRIPT_PATH}}"
+
+# create an alias to connect to the docker container
+CONTAINER_BASH_ALIAS=container
+echo "alias $CONTAINER_BASH_ALIAS=\"{{CONTAINER_BASH_SCRIPT_PATH}}\"" >> /home/{{SSH_USERNAME}}/.bashrc
+echo "alias $CONTAINER_BASH_ALIAS=\"{{CONTAINER_BASH_SCRIPT_PATH}}\"" >> /root/.bashrc
+
+{{#IS_GPU_INSTANCE}}
+# install NVIDIA driver
+if ! command -v nvidia-smi &> /dev/null; then
+  DRIVER_INSTALLER_PATH=/opt/deeplearning/install-driver.sh
+  if [ -f "$DRIVER_INSTALLER_PATH" ]; then
+    $DRIVER_INSTALLER_PATH
+  fi
+fi
+{{/IS_GPU_INSTANCE}}
diff --git a/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/02_mount_volumes.sh b/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/02_mount_volumes.sh
new file mode 100644
index 0000000..70470ba
--- /dev/null
+++ b/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/02_mount_volumes.sh
@@ -0,0 +1,22 @@
+#!/bin/bash -xe
+
+DEVICE_NAMES=({{{DISK_DEVICE_NAMES}}})
+MOUNT_DIRS=({{{DISK_MOUNT_DIRS}}})
+
+for i in ${!DEVICE_NAMES[*]}
+do
+  DEVICE=/dev/disk/by-id/google-${DEVICE_NAMES[$i]}
+  MOUNT_DIR=${MOUNT_DIRS[$i]}
+
+  blkid -o value -s TYPE $DEVICE || mkfs -t ext4 $DEVICE
+  mkdir -p $MOUNT_DIR
+  mount $DEVICE $MOUNT_DIR
+  chmod 777 $MOUNT_DIR
+  resize2fs $DEVICE
+done
+
+# create directories for temporary volumes
+{{#TMP_VOLUME_DIRS}}
+mkdir -p {{PATH}}
+chmod 777 {{PATH}}
+{{/TMP_VOLUME_DIRS}}
diff --git a/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/03_set_docker_root.sh b/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/03_set_docker_root.sh
new file mode 100644
index 0000000..649c485
--- /dev/null
+++ b/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/03_set_docker_root.sh
@@ -0,0 +1,8 @@
+#!/bin/bash -xe
+
+# change docker data root directory
+if [ -n "{{DOCKER_DATA_ROOT_DIR}}" ]; then
+  jq '. + { "data-root": "{{DOCKER_DATA_ROOT_DIR}}" }' /etc/docker/daemon.json > /tmp/docker_daemon.json \
+    && mv /tmp/docker_daemon.json /etc/docker/daemon.json
+  service docker restart
+fi
diff --git a/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/04_sync_project.sh b/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/04_sync_project.sh
new file mode 100644
index 0000000..eae5449
--- /dev/null
+++ b/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/04_sync_project.sh
@@ -0,0 +1,9 @@
+#!/bin/bash -xe
+
+# create a project directory
+if [ -n "{{HOST_PROJECT_DIR}}" ]; then
+  mkdir -p "{{HOST_PROJECT_DIR}}"
+  chmod 777 "{{HOST_PROJECT_DIR}}"
+fi
+
+{{{SYNC_PROJECT_CMD}}}
diff --git a/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/05_run_instance_startup_commands.sh b/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/05_run_instance_startup_commands.sh
new file mode 100644
index 0000000..81fe95b
--- /dev/null
+++ b/spotty/providers/gcp/dm_templates/instance/data/startup_scripts/05_run_instance_startup_commands.sh
@@ -0,0 +1,8 @@
+#!/bin/bash -xe
+
+mkdir -p "{{INSTANCE_STARTUP_SCRIPTS_DIR}}"
+cat > "{{INSTANCE_STARTUP_SCRIPTS_DIR}}/instance_startup_commands.sh" <<'EOF2'
+{{{INSTANCE_STARTUP_COMMANDS}}}
+EOF2
+
+/bin/bash -xe "{{INSTANCE_STARTUP_SCRIPTS_DIR}}/instance_startup_commands.sh"
diff --git a/spotty/providers/gcp/deployment/dm_templates/instance/template.yaml b/spotty/providers/gcp/dm_templates/instance/data/template.yaml
similarity index 94%
rename from spotty/providers/gcp/deployment/dm_templates/instance/template.yaml
rename to spotty/providers/gcp/dm_templates/instance/data/template.yaml
index 0e41815..dce2af8 100644
--- a/spotty/providers/gcp/deployment/dm_templates/instance/template.yaml
+++ b/spotty/providers/gcp/dm_templates/instance/data/template.yaml
@@ -49,10 +49,10 @@ resources:
         items:
           - key: 'ssh-keys'
             value: |
-              ubuntu:ssh-rsa {{{PUB_KEY_VALUE}}} ubuntu
-          - key: 'user-data'
+              {{SSH_USERNAME}}:ssh-rsa {{{PUB_KEY_VALUE}}} {{SSH_USERNAME}}
+          - key: 'startup-script'
             value: |
-              {{{STARTUP_SCRIPT}}}
+              {{> STARTUP_SCRIPT}}
 
   - name: {{MACHINE_NAME}}-firewall-rule
     type: compute.v1.firewall
diff --git a/spotty/providers/gcp/dm_templates/instance/instance_template.py b/spotty/providers/gcp/dm_templates/instance/instance_template.py
new file mode 100644
index 0000000..c7f5876
--- /dev/null
+++ b/spotty/providers/gcp/dm_templates/instance/instance_template.py
@@ -0,0 +1,149 @@
+import os
+from typing import List
+import chevron
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.config.tmp_dir_volume import TmpDirVolume
+from spotty.config.validation import is_subdir
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.deployment.container.docker.docker_commands import DockerCommands
+from spotty.deployment.container.docker.scripts.container_bash_script import ContainerBashScript
+from spotty.deployment.container.docker.scripts.start_container_script import StartContainerScript
+from spotty.deployment.abstract_cloud_instance.file_structure import CONTAINER_BASH_SCRIPT_PATH, INSTANCE_STARTUP_SCRIPTS_DIR
+from spotty.providers.gcp.config.disk_volume import DiskVolume
+from spotty.providers.gcp.config.instance_config import InstanceConfig
+
+
+def prepare_instance_template(instance_config: InstanceConfig, docker_commands: DockerCommands, image_link: str,
+                              bucket_name: str, sync_project_cmd: str, public_key_value: str,
+                              service_account_email: str, output: AbstractOutputWriter):
+    """Prepares deployment template to run an instance."""
+
+    # get disk attachments
+    disk_attachments, disk_device_names, disk_mount_dirs = \
+        _get_disk_attachments(instance_config.volumes, instance_config.zone)
+
+    # run sync command as a non-root user
+    if instance_config.container_config.run_as_host_user:
+        sync_project_cmd = 'sudo -u %s %s' % (instance_config.user, sync_project_cmd)
+
+    startup_scripts_templates = [
+        {
+            'filename': '01_prepare_instance.sh',
+            'params': {
+                'CONTAINER_BASH_SCRIPT_PATH': CONTAINER_BASH_SCRIPT_PATH,
+                'CONTAINER_BASH_SCRIPT': ContainerBashScript(docker_commands).render(),
+                'IS_GPU_INSTANCE': bool(instance_config.gpu),
+                'SSH_USERNAME': instance_config.user,
+            },
+        },
+        {
+            'filename': '02_mount_volumes.sh',
+            'params': {
+                'DISK_DEVICE_NAMES': ('"%s"' % '" "'.join(disk_device_names)) if disk_device_names else '',
+                'DISK_MOUNT_DIRS': ('"%s"' % '" "'.join(disk_mount_dirs)) if disk_mount_dirs else '',
+                'TMP_VOLUME_DIRS': [{'PATH': volume.host_path} for volume in instance_config.volumes
+                                    if isinstance(volume, TmpDirVolume)],
+            },
+        },
+        {
+            'filename': '03_set_docker_root.sh',
+            'params': {
+                'DOCKER_DATA_ROOT_DIR': instance_config.docker_data_root,
+            },
+        },
+        {
+            'filename': '04_sync_project.sh',
+            'params': {
+                'HOST_PROJECT_DIR': instance_config.host_project_dir,
+                'SYNC_PROJECT_CMD': sync_project_cmd,
+            },
+        },
+        {
+            'filename': '05_run_instance_startup_commands.sh',
+            'params': {
+                'INSTANCE_STARTUP_SCRIPTS_DIR': INSTANCE_STARTUP_SCRIPTS_DIR,
+                'INSTANCE_STARTUP_COMMANDS': instance_config.commands,
+            },
+        },
+    ]
+
+    # render startup scripts
+    startup_scripts_content = []
+    for template in startup_scripts_templates:
+        with open(os.path.join(os.path.dirname(__file__), 'data', 'startup_scripts', template['filename'])) as f:
+            content = f.read()
+
+        startup_scripts_content.append({
+            'filename': template['filename'],
+            'content': chevron.render(content, template['params'])
+        })
+
+    startup_scripts_content.append({
+        'filename': '06_start_container.sh',
+        'content': StartContainerScript(docker_commands).render(print_trace=True),
+    })
+
+    # render the main startup script
+    with open(os.path.join(os.path.dirname(__file__), 'data', 'startup_script.sh.tpl')) as f:
+        startup_script = f.read()
+
+    startup_script = chevron.render(startup_script, {
+        'MACHINE_NAME': instance_config.machine_name,
+        'INSTANCE_STARTUP_SCRIPTS_DIR': INSTANCE_STARTUP_SCRIPTS_DIR,
+        'STARTUP_SCRIPTS': startup_scripts_content,
+    })
+
+    # render the template
+    with open(os.path.join(os.path.dirname(__file__), 'data', 'template.yaml')) as f:
+        template = f.read()
+
+    template = chevron.render(template, {
+        'SERVICE_ACCOUNT_EMAIL': service_account_email,
+        'ZONE': instance_config.zone,
+        'MACHINE_TYPE': instance_config.machine_type,
+        'SOURCE_IMAGE': image_link,
+        'BOOT_DISK_SIZE': instance_config.boot_disk_size,
+        'MACHINE_NAME': instance_config.machine_name,
+        'PREEMPTIBLE': 'true' if instance_config.is_preemptible_instance else 'false',
+        'GPU_TYPE': instance_config.gpu['type'] if instance_config.gpu else '',
+        'GPU_COUNT': instance_config.gpu['count'] if instance_config.gpu else 0,
+        'DISK_ATTACHMENTS': disk_attachments,
+        'SSH_USERNAME': instance_config.user,
+        'PUB_KEY_VALUE': public_key_value,
+        'PORTS': ', '.join([str(port) for port in set([22] + instance_config.ports)]),
+    }, partials_dict={
+        'STARTUP_SCRIPT': startup_script,
+    })
+
+    # print some information about the deployment
+    output.write('- image URL: ' + '/'.join(image_link.split('/')[-5:]))
+    output.write('- zone: ' + instance_config.zone)
+    output.write('- preemptible VM' if instance_config.is_preemptible_instance else '- on-demand VM')
+    output.write(('- GPUs: %d x %s' % (instance_config.gpu['count'], instance_config.gpu['type']))
+                 if instance_config.gpu else '- no GPUs')
+
+    # print name of the volume where Docker data will be stored
+    if instance_config.docker_data_root:
+        docker_data_volume_name = [volume.name for volume in instance_config.volumes
+                                   if is_subdir(instance_config.docker_data_root, volume.host_path)][0]
+        output.write('- Docker data will be stored on the "%s" volume' % docker_data_volume_name)
+
+    return template
+
+
+def _get_disk_attachments(volumes: List[AbstractInstanceVolume], zone: str):
+    disk_attachments = []
+    disk_device_names = []
+    disk_mount_dirs = []
+
+    for i, volume in enumerate(volumes):
+        if isinstance(volume, DiskVolume):
+            device_name = 'disk-%d' % (i + 1)
+            disk_device_names.append(device_name)
+            disk_mount_dirs.append(volume.mount_dir)
+            disk_attachments.append({
+                'DISK_LINK': 'zones/%s/disks/%s' % (zone, volume.disk_name),
+                'DEVICE_NAME': device_name,
+            })
+
+    return disk_attachments, disk_device_names, disk_mount_dirs
diff --git a/spotty/providers/gcp/helpers/ce_client.py b/spotty/providers/gcp/helpers/ce_client.py
index 22705ba..10e0c13 100644
--- a/spotty/providers/gcp/helpers/ce_client.py
+++ b/spotty/providers/gcp/helpers/ce_client.py
@@ -1,4 +1,5 @@
 from collections import OrderedDict
+from time import sleep
 import googleapiclient.discovery
 
 
@@ -87,3 +88,42 @@ def create_disk(self, name: str, size: int = None, snapshot_link: str = None) ->
         res = self._client.disks().insert(project=self._project_id, zone=self._zone, body=params).execute()
 
         return res['targetLink']
+
+    def get_machine_types(self, machine_type: str = None):
+        """Returns a list of images that satisfy the name.
+            This method is used instead of the "get" because it doesn't raise an exception if an image doesn't exist.
+        """
+        filter_str = ('name=%s' % machine_type) if machine_type else None
+        res = self._client.machineTypes().list(project=self._project_id, zone=self._zone, filter=filter_str).execute()
+
+        if not res.get('items'):
+            return []
+
+        return res['items']
+
+    def stop_instance(self, machine_name: str, wait: bool = True) -> str:
+        """Stops the instance."""
+        operation = self._client.instances().stop(project=self._project_id, zone=self._zone,
+                                            instance=machine_name).execute()
+        if wait:
+            operation = self._wait_operation(operation)
+
+        return operation['targetLink']
+
+    def delete_instance(self, machine_name: str, wait: bool = True) -> str:
+        """Deletes the instance."""
+        operation = self._client.instances().delete(project=self._project_id, zone=self._zone,
+                                                    instance=machine_name).execute()
+        if wait:
+            operation = self._wait_operation(operation)
+
+        return operation['targetLink']
+
+    def _wait_operation(self, operation: dict):
+        """Waits util the operation is finished/"""
+        while operation['status'] != 'DONE':
+            sleep(5)
+            operation = self._client.zoneOperations().wait(project=self._project_id, zone=self._zone,
+                                                           operation=operation['name']).execute()
+
+        return operation
diff --git a/spotty/providers/gcp/helpers/deployment.py b/spotty/providers/gcp/helpers/deployment.py
index 6ef4808..804cf68 100644
--- a/spotty/providers/gcp/helpers/deployment.py
+++ b/spotty/providers/gcp/helpers/deployment.py
@@ -3,8 +3,8 @@
 from time import sleep
 from httplib2 import ServerNotFoundError
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.gcp.gcp_resources.instance import Instance
-from spotty.providers.gcp.gcp_resources.stack import Stack
+from spotty.providers.gcp.resources.instance import Instance
+from spotty.providers.gcp.resources.stack import Stack
 from spotty.providers.gcp.helpers.ce_client import CEClient
 from spotty.providers.gcp.helpers.dm_client import DMClient
 from spotty.providers.gcp.helpers.dm_resource import DMResource
@@ -34,7 +34,7 @@ def wait_resources(dm: DMClient, ce: CEClient, deployment_name: str, resource_me
                 # check if the instance was preempted, terminated or deleted right after creation
                 if instance_resource_name in created_resources:
                     instance = Instance.get_by_name(ce, machine_name)
-                    if not instance or instance.is_terminated:
+                    if not instance or instance.is_stopped:
                         raise ValueError('Error: the instance was unexpectedly terminated. Please, check out the '
                                          'instance logs to find out the reason.\n')
 
diff --git a/spotty/providers/gcp/helpers/gsutil.py b/spotty/providers/gcp/helpers/gsutil.py
deleted file mode 100644
index abaad03..0000000
--- a/spotty/providers/gcp/helpers/gsutil.py
+++ /dev/null
@@ -1,156 +0,0 @@
-import json
-import logging
-import shlex
-from shutil import which
-import subprocess
-import re
-# from awscli.customizations.s3.fileinfo import FileInfo
-# from awscli.customizations.s3.filters import Filter
-import os
-
-
-class GsutilCommandError(Exception):
-    def __init__(self, msg: str):
-        super().__init__(msg)
-
-
-class GSUtil(object):
-
-    def __init__(self):
-        pass
-
-    def rsync(self, from_path: str, to_path: str, delete=False, filters=None, capture_output=True,
-              dry_run=False) -> str:
-        if from_path.startswith('gs://') or not to_path.startswith('gs://'):
-            raise NotImplementedError
-
-        args = ['rsync', '-r']
-        args += self.get_rsync_arguments(filters, delete=delete, dry_run=dry_run)
-        args += [from_path, to_path]
-
-        return self._run(args, False, capture_output=capture_output)
-
-    @staticmethod
-    def get_rsync_arguments(filters: list = None, delete: bool = False, quote: bool = False, dry_run: bool = False):
-        args = []
-
-        if dry_run:
-            args.append('-n')
-
-        if delete:
-            args.append('-d')
-
-        # file_infos = []
-        # for file_path in glob.iglob(os.path.join('**', '*'), recursive=True):
-        #     file_infos.append(FileInfo(file_path, src_type='local'))
-
-        # filters = self.create_filter(filters, from_path)
-        # included_file_infos = list(filters.call(file_infos))
-        # exclude_files = [file_info.src for file_info in file_infos if file_info not in included_file_infos]
-        # args += ['-x', '^(%s)$' % '|'.join(exclude_files)]
-
-        if filters:
-            if len(filters) > 1 or ('include' in filters[0]):
-                raise ValueError('At the moment GCP provider supports only one list of exclude filters.')
-
-            sync_filter = filters[0]
-            if ('exclude' in sync_filter and 'include' in sync_filter) \
-                    or ('exclude' not in sync_filter and 'include' not in sync_filter):
-                raise ValueError('GCP sync filter has a wrong format.')
-
-            path_regs = []
-            for path in sync_filter['exclude']:
-                path = path.replace('/', os.sep)  # fix for Windows machines
-                path_regs.append(GSUtil.fnmatch_translate(path))
-
-            filter_regex = '^(%s)$' % '|'.join(path_regs)
-            args += ['-x', shlex.quote(filter_regex) if quote else filter_regex]
-
-        return args
-
-    def _run(self, args: list, json_format=True, capture_output=True):
-        gsutil_cmd = 'gsutil'
-        if which(gsutil_cmd) is None:
-            raise ValueError('gsutil is not installed.')
-
-        command_args = [gsutil_cmd, '-m'] + args
-
-        logging.debug('gsutil command: ' + subprocess.list2cmdline(command_args))
-
-        if capture_output:
-            res = subprocess.run(command_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-            output = res.stdout.decode('utf-8')
-
-            logging.debug('gsutil command output: ' + output)
-
-            if res.returncode:
-                raise GsutilCommandError(output)
-
-            if json_format:
-                output = json.loads(output)
-        else:
-            res = subprocess.run(command_args)
-            output = None
-
-            if res.returncode:
-                raise GsutilCommandError('"gsutil" command failed.')
-
-        return output
-
-    @staticmethod
-    def fnmatch_translate(pat):
-        """This is a copy-paste of the fnmatch.translate() function
-        to prevent wrapping of a regular expression to a group.
-
-        Translate a shell PATTERN to a regular expression.
-
-        There is no way to quote meta-characters.
-        """
-        i, n = 0, len(pat)
-        res = ''
-        while i < n:
-            c = pat[i]
-            i = i + 1
-            if c == '*':
-                res = res + '.*'
-            elif c == '?':
-                res = res + '.'
-            elif c == '[':
-                j = i
-                if j < n and pat[j] == '!':
-                    j = j + 1
-                if j < n and pat[j] == ']':
-                    j = j + 1
-                while j < n and pat[j] != ']':
-                    j = j + 1
-                if j >= n:
-                    res = res + '\\['
-                else:
-                    stuff = pat[i:j].replace('\\', '\\\\')
-                    i = j + 1
-                    if stuff[0] == '!':
-                        stuff = '^' + stuff[1:]
-                    elif stuff[0] == '^':
-                        stuff = '\\' + stuff
-                    res = '%s[%s]' % (res, stuff)
-            else:
-                res = res + re.escape(c)
-
-        return res
-
-    # @staticmethod
-    # def create_filter(filters: list, base_dir: str):
-    #     if filters:
-    #         filter_list = []
-    #         for sync_filter in filters:
-    #             if (len(sync_filter) != 1) or not ('exclude' in sync_filter or 'include' in sync_filter):
-    #                 raise ValueError('GCP synchronization filters have wrong format.')
-    #
-    #             for filter_type in ['exclude', 'include']:
-    #                 if filter_type in sync_filter:
-    #                     for filter_pattern in sync_filter[filter_type]:
-    #                         filter_list.append((filter_type, filter_pattern))
-    #
-    #         return Filter(filter_list, base_dir, base_dir)
-    #     else:
-    #         return Filter([], None, None)
diff --git a/spotty/providers/gcp/helpers/gsutil_rsync.py b/spotty/providers/gcp/helpers/gsutil_rsync.py
new file mode 100644
index 0000000..7f8a499
--- /dev/null
+++ b/spotty/providers/gcp/helpers/gsutil_rsync.py
@@ -0,0 +1,43 @@
+import fnmatch
+from shutil import which
+import os
+from typing import List
+
+from spotty.deployment.utils.cli import shlex_join
+
+
+def check_gsutil_installed():
+    """Checks that gsutil is installed."""
+    if which('gsutil') is None:
+        raise ValueError('gsutil is not installed.')
+
+
+def get_rsync_command(from_path: str, to_path: str, filters: List[dict] = None, delete: bool = False,
+                      quiet: bool = False, dry_run: bool = False):
+    args = ['gsutil', '-m']
+    if quiet:
+        args.append('-q')
+
+    args += ['rsync', '-r']
+
+    if filters:
+        if (len(filters) > 1) or (len(filters[0]) > 1) or ('include' in filters[0]):
+            raise ValueError('At the moment GCP provider supports only one list of exclude filters.')
+
+        path_regs = []
+        for path in filters[0]['exclude']:
+            path = path.replace('/', os.sep)  # fix for Windows machines
+            path_regs.append(fnmatch.translate(path)[4:-3])
+
+        filter_regex = '^(%s)$' % '|'.join(path_regs)
+        args += ['-x', filter_regex]
+
+    if delete:
+        args.append('-d')
+
+    if dry_run:
+        args.append('-n')
+
+    args += [from_path, to_path]
+
+    return shlex_join(args)
diff --git a/spotty/providers/gcp/helpers/image.py b/spotty/providers/gcp/helpers/image.py
new file mode 100644
index 0000000..a2327b5
--- /dev/null
+++ b/spotty/providers/gcp/helpers/image.py
@@ -0,0 +1,33 @@
+from spotty.providers.gcp.config.instance_config import DEFAULT_IMAGE_NAME
+from spotty.providers.gcp.helpers.ce_client import CEClient
+from spotty.providers.gcp.resources.image import Image
+
+
+def get_image(ce: CEClient, image_uri: str = None, image_name: str = None) -> Image:
+    """Returns an image that should be used for deployment.
+
+    Raises:
+        ValueError: If an image not found.
+    """
+    if image_uri:
+        # get an image by its URL if the "imageUri" parameter is specified
+        image = Image.get_by_uri(ce, image_uri)
+        if not image:
+            raise ValueError('Image "%s" not found.' % image_uri)
+    elif image_name:
+        # get an image by name if the "imageName" parameter is specified
+        image = Image.get_by_name(ce, image_name)
+        if not image:
+            # if an image name was explicitly specified, but the image was not found, raise an error
+            raise ValueError('Image with the name "%s" was not found.' % image_name)
+    else:
+        # if the "imageName" parameter is not specified, try to use the default image name
+        image = Image.get_by_name(ce, DEFAULT_IMAGE_NAME)
+        if not image:
+            # get the latest "common-gce-gpu-image" image
+            image_family_url = 'projects/ml-images/global/images/family/common-gce-gpu-image'
+            image = Image.get_by_uri(ce, image_family_url)
+            if not image:
+                raise ValueError('The "common-gce-gpu-image" image was not found.')
+
+    return image
diff --git a/spotty/providers/gcp/helpers/sync.py b/spotty/providers/gcp/helpers/sync.py
deleted file mode 100644
index 054ada9..0000000
--- a/spotty/providers/gcp/helpers/sync.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import subprocess
-from spotty.helpers.ssh import get_ssh_command
-from spotty.providers.gcp.helpers.gsutil import GSUtil
-
-
-BUCKET_SYNC_DIR = 'project'
-
-
-def get_project_gs_path(bucket_name: str):
-    return 'gs://%s/%s' % (bucket_name, BUCKET_SYNC_DIR)
-
-
-def get_instance_sync_arguments(sync_filters: list):
-    """Returns arguments for the "gsutil rsync" command that will be run the instance side."""
-    return GSUtil.get_rsync_arguments(sync_filters, quote=True)
-
-
-def sync_local_to_bucket(project_dir, bucket_name, sync_filters, dry_run=False):
-    # sync the project from local to the bucket, deleted local files will be deleted from the bucket
-    GSUtil().rsync(project_dir, get_project_gs_path(bucket_name), filters=sync_filters, delete=True,
-                   capture_output=False, dry_run=dry_run)
-
-
-def sync_bucket_to_instance(sync_filters: list, host: str, port: int, user: str, key_path: str):
-    """Syncs the project from the bucket to the instance."""
-    remote_cmd = subprocess.list2cmdline(['sudo', '/tmp/spotty/instance/scripts/sync_project.sh',
-                                          *get_instance_sync_arguments(sync_filters), '>', '/dev/null'])
-
-    # connect to the instance and run remote command
-    ssh_command = get_ssh_command(host, port, user, key_path, remote_cmd, quiet=True)
-    subprocess.call(ssh_command)
diff --git a/spotty/providers/gcp/helpers/volumes.py b/spotty/providers/gcp/helpers/volumes.py
new file mode 100644
index 0000000..23b80ec
--- /dev/null
+++ b/spotty/providers/gcp/helpers/volumes.py
@@ -0,0 +1,60 @@
+from typing import List
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.providers.gcp.config.disk_volume import DiskVolume
+from spotty.providers.gcp.helpers.ce_client import CEClient
+from spotty.providers.gcp.resources.disk import Disk
+from spotty.providers.gcp.resources.snapshot import Snapshot
+
+
+def create_disks(ce: CEClient, volumes: List[AbstractInstanceVolume], output: AbstractOutputWriter,
+                 dry_run: bool = False):
+    disks_to_create = []
+
+    # do some checks and prepare disk parameters
+    for i, volume in enumerate(volumes):
+        if isinstance(volume, DiskVolume):
+            # check if the disk already exists
+            disk = Disk.get_by_name(ce, volume.disk_name)
+            if disk:
+                # check if the volume is available
+                if not disk.is_available():
+                    raise ValueError('Disk "%s" is not available (status: %s).'
+                                     % (volume.disk_name, disk.status))
+
+                # check size of the volume
+                if volume.size and (volume.size != disk.size):
+                    raise ValueError('Specified size for the "%s" volume (%dGB) doesn\'t match the size of the '
+                                     'existing disk (%dGB).' % (volume.name, volume.size, disk.size))
+
+                output.write('- disk "%s" will be attached' % disk.name)
+            else:
+                # check if the snapshot exists
+                snapshot = Snapshot.get_by_name(ce, volume.disk_name)
+                if snapshot:
+                    # disk will be restored from the snapshot
+                    # check size of the volume
+                    if volume.size and (volume.size < snapshot.size):
+                        raise ValueError('Specified size for the "%s" volume (%dGB) is less than size of the '
+                                         'snapshot (%dGB).'
+                                         % (volume.name, volume.size, snapshot.size))
+
+                    output.write('- disk "%s" will be restored from the snapshot' % volume.disk_name)
+
+                    disks_to_create.append((volume.disk_name, volume.size, snapshot.self_link))
+                else:
+                    # empty volume will be created, check that the size is specified
+                    if not volume.size:
+                        raise ValueError('Size for the new disk is required.')
+
+                    if volume.size < 10:
+                        raise ValueError('Size of a disk cannot be less than 10GB.')
+
+                    disks_to_create.append((volume.disk_name, volume.size, None))
+
+    # create disks
+    for disk_name, disk_size, snapshot_link in disks_to_create:
+        if not dry_run:
+            ce.create_disk(disk_name, disk_size, snapshot_link)
+
+        output.write('- disk "%s" was created' % disk_name)
diff --git a/spotty/providers/gcp/instance_deployment.py b/spotty/providers/gcp/instance_deployment.py
new file mode 100644
index 0000000..e4e9ce0
--- /dev/null
+++ b/spotty/providers/gcp/instance_deployment.py
@@ -0,0 +1,102 @@
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.deployment.abstract_cloud_instance.abstract_instance_deployment import AbstractInstanceDeployment
+from spotty.deployment.container.docker.docker_commands import DockerCommands
+from spotty.deployment.utils.print_info import render_volumes_info_table
+from spotty.providers.gcp.config.instance_config import InstanceConfig
+from spotty.providers.gcp.data_transfer import DataTransfer
+from spotty.providers.gcp.dm_templates.instance.instance_template import prepare_instance_template
+from spotty.providers.gcp.helpers.image import get_image
+from spotty.providers.gcp.helpers.volumes import create_disks
+from spotty.providers.gcp.resource_managers.instance_stack_manager import InstanceStackManager
+from spotty.providers.gcp.helpers.ce_client import CEClient
+from spotty.providers.gcp.helpers.gcp_credentials import GcpCredentials
+from spotty.providers.gcp.resource_managers.ssh_key_manager import SshKeyManager
+from spotty.providers.gcp.resources.instance import Instance
+from spotty.providers.gcp.helpers.deployment import check_gpu_configuration
+
+
+class InstanceDeployment(AbstractInstanceDeployment):
+
+    instance_config: InstanceConfig
+
+    def __init__(self, instance_config: InstanceConfig):
+        super().__init__(instance_config)
+
+        self._project_name = instance_config.project_config.project_name
+        self._credentials = GcpCredentials()
+        self._ce = CEClient(self._credentials.project_id, instance_config.zone)
+
+    @property
+    def stack_manager(self) -> InstanceStackManager:
+        return InstanceStackManager(self.instance_config.machine_name, self._credentials.project_id, self.instance_config.zone)
+
+    @property
+    def ssh_key_manager(self) -> SshKeyManager:
+        return SshKeyManager(self._project_name, self.instance_config.zone)
+
+    def get_instance(self) -> Instance:
+        return Instance.get_by_name(self._ce, self.instance_config.machine_name)
+
+    def deploy(self, container_commands: DockerCommands, bucket_name: str,
+               data_transfer: DataTransfer, output: AbstractOutputWriter, dry_run: bool = False):
+        # check machine type
+        if not self._ce.get_machine_types(self.instance_config.machine_type):
+            raise ValueError('"%s" machine type is not available in the "%s" zone.'
+                             % (self.instance_config.machine_type, self.instance_config.zone))
+
+        # check GPU configuration
+        check_gpu_configuration(self._ce, self.instance_config.gpu)
+
+        # remove the stack it it exists to make all the disks available
+        stack_manager = self.stack_manager
+        stack_manager.delete_stack(output=output)
+
+        # sync the project with the S3 bucket
+        if bucket_name is not None:
+            output.write('Syncing the project with the bucket...')
+            data_transfer.upload_local_to_bucket(bucket_name, dry_run=dry_run)
+
+        # create volumes
+        if self.instance_config.volumes:
+            # create disks
+            output.write('\nCreating disks...')
+            with output.prefix('  '):
+                create_disks(self._ce, self.instance_config.volumes, output=output, dry_run=dry_run)
+            output.write('')
+
+        # prepare Deployment Manager template
+        output.write('Preparing the deployment template...')
+        with output.prefix('  '):
+            # get an image
+            image_link = get_image(self._ce, self.instance_config.image_uri, self.instance_config.image_name).self_link
+
+            # get or create an SSH key
+            public_key_value = self.ssh_key_manager.get_public_key_value()
+
+            # prepare the deployment template
+            sync_project_cmd = data_transfer.get_download_bucket_to_instance_command(bucket_name=bucket_name)
+            template = prepare_instance_template(
+                instance_config=self.instance_config,
+                docker_commands=container_commands,
+                image_link=image_link,
+                bucket_name=bucket_name,
+                sync_project_cmd=sync_project_cmd,
+                public_key_value=public_key_value,
+                service_account_email=self._credentials.service_account_email,
+                output=output,
+            )
+
+        output.write('')
+
+        # print information about the volumes
+        output.write('Volumes:\n%s\n' % render_volumes_info_table(self.instance_config.volume_mounts,
+                                                                  self.instance_config.volumes))
+
+        # create stack
+        if not dry_run:
+            stack_manager.create_stack(template, output=output)
+
+    def delete(self, output: AbstractOutputWriter):
+        self.stack_manager.delete_stack(output)
+
+        # TODO: apply deletion policies
diff --git a/spotty/providers/gcp/instance_manager.py b/spotty/providers/gcp/instance_manager.py
index 7f8dbbd..dbf427b 100644
--- a/spotty/providers/gcp/instance_manager.py
+++ b/spotty/providers/gcp/instance_manager.py
@@ -1,78 +1,44 @@
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
 from spotty.errors.instance_not_running import InstanceNotRunningError
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
+from spotty.deployment.abstract_cloud_instance.abstract_cloud_instance_manager import AbstractCloudInstanceManager
 from spotty.providers.gcp.config.instance_config import InstanceConfig
-from spotty.providers.gcp.deployment.image_deployment import ImageDeployment
-from spotty.providers.gcp.deployment.instance_deployment import InstanceDeployment
-from spotty.providers.gcp.helpers.sync import sync_local_to_bucket, sync_bucket_to_instance
+from spotty.providers.gcp.data_transfer import DataTransfer
+from spotty.providers.gcp.instance_deployment import InstanceDeployment
+from spotty.providers.gcp.resource_managers.bucket_manager import BucketManager
 from spotty.utils import render_table
 
 
-class InstanceManager(AbstractInstanceManager):
+class InstanceManager(AbstractCloudInstanceManager):
 
-    @property
-    def instance_deployment(self) -> InstanceDeployment:
-        """Returns an instance deployment manager."""
-        return InstanceDeployment(self.project_config.project_name, self.instance_config)
-
-    @property
-    def image_deployment(self) -> ImageDeployment:
-        """Returns an image deployment manager."""
-        return ImageDeployment(self.project_config.project_name, self.instance_config)
-
-    def _get_instance_config(self, config: dict) -> InstanceConfig:
-        return InstanceConfig(config)
-
-    @property
-    def instance_config(self) -> InstanceConfig:
-        """This property is redefined just for a correct type hinting."""
-        return self._instance_config
-
-    def is_running(self):
-        return bool(self.instance_deployment.get_instance())
-
-    def start(self, output: AbstractOutputWriter, dry_run=False):
-        deployment = self.instance_deployment
-
-        if not dry_run:
-            # check if the instance is already running
-            instance = deployment.get_instance()
-            if instance and instance.is_running:
-                print('Instance is already running. Are you sure you want to restart it?')
-                res = input('Type "y" to confirm: ')
-                if res != 'y':
-                    raise ValueError('The operation was cancelled.')
+    instance_config: InstanceConfig
+    bucket_manager: BucketManager
+    data_transfer: DataTransfer
+    instance_deployment: InstanceDeployment
 
-                # terminating the instance without applying deletion policies
-                self.instance_deployment.stack.delete_stack(output)
+    def _get_instance_config(self, instance_config: dict) -> InstanceConfig:
+        """Validates the instance config and returns an InstanceConfig object."""
+        return InstanceConfig(instance_config, self.project_config)
 
-        # deploy the instance
-        deployment.deploy(self.project_config, output, dry_run=dry_run)
+    def _get_bucket_manager(self) -> BucketManager:
+        region = '-'.join(self.instance_config.zone.split('-')[:-1])
+        return BucketManager(self.instance_config.project_config.project_name, region)
 
-    def stop(self, output: AbstractOutputWriter):
-        # delete the deployment and apply deletion policies for the volumes
-        self.instance_deployment.delete(output)
+    def _get_data_transfer(self) -> DataTransfer:
+        """Returns a data transfer object."""
+        return DataTransfer(
+            local_project_dir=self.project_config.project_dir,
+            host_project_dir=self.instance_config.host_project_dir,
+            sync_filters=self.project_config.sync_filters,
+            instance_name=self.instance_config.name,
+        )
 
-    def sync(self, output: AbstractOutputWriter, dry_run=False):
-        # create or get existing bucket for the project
-        bucket_name = self.instance_deployment.bucket.get_or_create_bucket(output, dry_run)
-
-        # sync the project with the bucket
-        output.write('Syncing the project with the bucket...')
-        sync_local_to_bucket(self.project_config.project_dir, bucket_name, self.project_config.sync_filters, dry_run)
-
-        if not dry_run:
-            # sync the bucket with the instance
-            output.write('Syncing the bucket with the instance...')
-            sync_bucket_to_instance(self.project_config.sync_filters, self.get_ip_address(), self.ssh_port,
-                                    self.ssh_user, self.ssh_key_path)
+    def _get_instance_deployment(self) -> InstanceDeployment:
+        """Returns an instance deployment manager."""
+        return InstanceDeployment(self.instance_config)
 
     def download(self, download_filters: list, output: AbstractOutputWriter, dry_run=False):
         raise NotImplementedError('GCP provider doesn\'t have an implementation of the "download" command yet.')
 
-    def clean(self, output: AbstractOutputWriter):
-        raise NotImplementedError
-
     def get_status_text(self) -> str:
         instance = self.instance_deployment.get_instance()
         if not instance:
@@ -87,23 +53,10 @@ def get_status_text(self) -> str:
         if instance.public_ip_address:
             table.append(('Public IP Address', instance.public_ip_address))
 
-        table.append(('Launch Time', instance.creation_timestamp.today().strftime('%Y-%m-%d %H:%M:%S')))
         table.append(('Purchasing Option', 'Preemtible VM' if instance.is_preemtible else 'On-demand VM'))
 
         return render_table(table)
 
-    def get_public_ip_address(self) -> str:
-        """Returns a public IP address of the running instance."""
-        instance = self.instance_deployment.get_instance()
-        if not instance or not instance.is_running:
-            raise InstanceNotRunningError(self.instance_config.name)
-
-        return instance.public_ip_address
-
-    @property
-    def ssh_user(self):
-        return 'ubuntu'
-
     @property
     def ssh_key_path(self):
-        return self.instance_deployment.ssh_key.private_key_file
+        return self.instance_deployment.ssh_key_manager.private_key_file
diff --git a/spotty/providers/gcp/resource_managers/__init__.py b/spotty/providers/gcp/resource_managers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/spotty/providers/gcp/resource_managers/bucket_manager.py b/spotty/providers/gcp/resource_managers/bucket_manager.py
new file mode 100644
index 0000000..4f0d55e
--- /dev/null
+++ b/spotty/providers/gcp/resource_managers/bucket_manager.py
@@ -0,0 +1,39 @@
+import re
+from spotty.deployment.abstract_cloud_instance.abstract_bucket_manager import AbstractBucketManager
+from spotty.deployment.abstract_cloud_instance.errors.bucket_not_found import BucketNotFoundError
+from spotty.providers.gcp.helpers.gs_client import GSClient
+from spotty.providers.gcp.resources.bucket import Bucket
+from spotty.utils import random_string
+
+
+class BucketManager(AbstractBucketManager):
+
+    def __init__(self, project_name: str, region: str):
+        super().__init__(project_name)
+
+        self._gs = GSClient()
+        self._region = region
+        self._bucket_prefix = 'spotty-%s' % project_name.lower()
+
+    def get_bucket(self) -> Bucket:
+        buckets = self._gs.list_buckets()
+
+        regex = re.compile('-'.join([self._bucket_prefix, '[a-z0-9]{12}', self._region]))
+        buckets = [bucket for bucket in buckets if regex.match(bucket.name) is not None]
+
+        if len(buckets) > 1:
+            raise ValueError('Found several project buckets in the same region: %s.'
+                             % ', '.join(bucket.name for bucket in buckets))
+
+        if not len(buckets):
+            raise BucketNotFoundError
+
+        bucket = Bucket(buckets[0])
+
+        return bucket
+
+    def create_bucket(self) -> Bucket:
+        bucket_name = '-'.join([self._bucket_prefix, random_string(12), self._region])
+        bucket = self._gs.create_bucket(bucket_name, self._region)
+
+        return Bucket(bucket)
diff --git a/spotty/providers/gcp/deployment/project_resources/instance_stack.py b/spotty/providers/gcp/resource_managers/instance_stack_manager.py
similarity index 94%
rename from spotty/providers/gcp/deployment/project_resources/instance_stack.py
rename to spotty/providers/gcp/resource_managers/instance_stack_manager.py
index 5468bc2..3aa1fe7 100644
--- a/spotty/providers/gcp/deployment/project_resources/instance_stack.py
+++ b/spotty/providers/gcp/resource_managers/instance_stack_manager.py
@@ -1,6 +1,6 @@
 from collections import OrderedDict
 from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
-from spotty.providers.gcp.gcp_resources.stack import Stack
+from spotty.providers.gcp.resources.stack import Stack
 from spotty.providers.gcp.helpers.ce_client import CEClient
 from spotty.providers.gcp.helpers.deployment import wait_resources
 from spotty.providers.gcp.helpers.dm_client import DMClient
@@ -8,7 +8,7 @@
 from spotty.providers.gcp.helpers.rtc_client import RtcClient
 
 
-class InstanceStackResource(object):
+class InstanceStackManager(object):
 
     def __init__(self, machine_name: str, project_id: str, zone: str):
         self._dm = DMClient(project_id, zone)
@@ -30,7 +30,9 @@ def create_stack(self, template: str, output: AbstractOutputWriter):
         """Deploys a Deployment Manager template."""
 
         # create a stack
-        Stack.create(self._dm, self._stack_name, template)
+        res = Stack.create(self._dm, self._stack_name, template)
+        # print(res)
+        # exit()
 
         output.write('Waiting for the stack to be created...')
 
diff --git a/spotty/providers/gcp/helpers/ssh_key.py b/spotty/providers/gcp/resource_managers/ssh_key_manager.py
similarity index 86%
rename from spotty/providers/gcp/helpers/ssh_key.py
rename to spotty/providers/gcp/resource_managers/ssh_key_manager.py
index ca25e37..b1d3f49 100644
--- a/spotty/providers/gcp/helpers/ssh_key.py
+++ b/spotty/providers/gcp/resource_managers/ssh_key_manager.py
@@ -2,13 +2,14 @@
 import subprocess
 from spotty.configuration import get_spotty_keys_dir
 from shutil import which
+from spotty.providers.instance_manager_factory import PROVIDER_GCP
 
 
-class SshKey(object):
+class SshKeyManager(object):
 
-    def __init__(self, project_name: str, zone: str, provider_name: str):
+    def __init__(self, project_name: str, zone: str):
         self._key_name = 'spotty-key-%s-%s' % (project_name.lower(), zone)
-        self._keys_dir = os.path.join(get_spotty_keys_dir(), provider_name)
+        self._keys_dir = get_spotty_keys_dir(PROVIDER_GCP)
 
     @property
     def private_key_file(self):
@@ -19,7 +20,7 @@ def public_key_file(self):
         return os.path.join(self._keys_dir, self._key_name + '.pub')
 
     def get_public_key_value(self):
-        # generate a key pair if it doesn't exist
+        # generate a key if it doesn't exist
         if not os.path.isfile(self.private_key_file) or not os.path.isfile(self.public_key_file):
             self._generate_ssh_key()
 
diff --git a/spotty/providers/gcp/resources/__init__.py b/spotty/providers/gcp/resources/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/spotty/providers/gcp/resources/bucket.py b/spotty/providers/gcp/resources/bucket.py
new file mode 100644
index 0000000..2b750ff
--- /dev/null
+++ b/spotty/providers/gcp/resources/bucket.py
@@ -0,0 +1,12 @@
+from spotty.deployment.abstract_cloud_instance.resources.abstract_bucket import AbstractBucket
+from google.cloud.storage import Bucket as GSBucket
+
+
+class Bucket(AbstractBucket):
+
+    def __init__(self, bucket: GSBucket):
+        self._bucket = bucket
+
+    @property
+    def name(self) -> str:
+        return self._bucket.name
diff --git a/spotty/providers/gcp/gcp_resources/disk.py b/spotty/providers/gcp/resources/disk.py
similarity index 98%
rename from spotty/providers/gcp/gcp_resources/disk.py
rename to spotty/providers/gcp/resources/disk.py
index 93bbc7d..f55d785 100644
--- a/spotty/providers/gcp/gcp_resources/disk.py
+++ b/spotty/providers/gcp/resources/disk.py
@@ -24,7 +24,7 @@ def __init__(self, ce: CEClient, data: dict):
                 'status': 'READY',
                 'type': 'https://www.googleapis.com/compute/v1/projects/spotty-221422/zones/us-east1-b/diskTypes/pd-standard',
                 'users': ['https://www.googleapis.com/compute/v1/projects/spotty-221422/zones/us-east1-b/instances/instance-1'],
-                'zone': 'https://www.googleapis.com/compute/v1/projects/spotty-221422/zones/us-east1-b'}]
+                'zone': 'https://www.googleapis.com/compute/v1/projects/spotty-221422/zones/us-east1-b'}
         """
         self._ce = ce
         self._data = data
diff --git a/spotty/providers/gcp/gcp_resources/image.py b/spotty/providers/gcp/resources/image.py
similarity index 86%
rename from spotty/providers/gcp/gcp_resources/image.py
rename to spotty/providers/gcp/resources/image.py
index 6c4027c..d369f51 100644
--- a/spotty/providers/gcp/gcp_resources/image.py
+++ b/spotty/providers/gcp/resources/image.py
@@ -1,4 +1,4 @@
-from spotty.providers.gcp.config.image_url import ImageUrl
+from spotty.providers.gcp.config.image_uri import ImageUri
 from spotty.providers.gcp.helpers.ce_client import CEClient
 
 
@@ -37,12 +37,12 @@ def get_by_name(ce: CEClient, image_name: str):
         return Image(res[0])
 
     @staticmethod
-    def get_by_url(ce: CEClient, image_url: str):
-        image_url = ImageUrl(image_url)
-        if image_url.is_family:
-            image_data = ce.get_image_from_family(family_name=image_url.name, project_id=image_url.project_id)
+    def get_by_uri(ce: CEClient, image_uri: str):
+        image_uri = ImageUri(image_uri)
+        if image_uri.is_family:
+            image_data = ce.get_image_from_family(family_name=image_uri.name, project_id=image_uri.project_id)
         else:
-            res = ce.list_images(image_name=image_url.name, project_id=image_url.project_id)
+            res = ce.list_images(image_name=image_uri.name, project_id=image_uri.project_id)
             image_data = res[0] if res else None
 
         if not image_data:
diff --git a/spotty/providers/gcp/gcp_resources/instance.py b/spotty/providers/gcp/resources/instance.py
similarity index 93%
rename from spotty/providers/gcp/gcp_resources/instance.py
rename to spotty/providers/gcp/resources/instance.py
index f736caa..2f13f21 100644
--- a/spotty/providers/gcp/gcp_resources/instance.py
+++ b/spotty/providers/gcp/resources/instance.py
@@ -1,8 +1,9 @@
 from datetime import datetime
+from spotty.deployment.abstract_cloud_instance.resources.abstract_instance import AbstractInstance
 from spotty.providers.gcp.helpers.ce_client import CEClient
 
 
-class Instance(object):
+class Instance(AbstractInstance):
 
     def __init__(self, ce: CEClient, data: dict):
         """
@@ -79,7 +80,7 @@ def is_running(self) -> bool:
         return self.status == 'RUNNING'
 
     @property
-    def is_terminated(self) -> bool:
+    def is_stopped(self) -> bool:
         # see Instance Life Cycle: https://cloud.google.com/compute/docs/instances/instance-life-cycle
         return self.status == 'TERMINATED'
 
@@ -100,7 +101,7 @@ def zone(self) -> str:
         return self._data['zone'].split('/')[-1]
 
     @property
-    def creation_timestamp(self) -> str:
+    def creation_timestamp(self) -> datetime:
         # fix the format: '2019-04-20T16:21:49.536-07:00' -> '2019-04-20T16:21:49-0700'
         time_str = self._data['creationTimestamp'][:-10] + \
                    self._data['creationTimestamp'][-6:-3] + \
@@ -110,3 +111,9 @@ def creation_timestamp(self) -> str:
     @property
     def is_preemtible(self) -> bool:
         return self._data['scheduling']['preemptible']
+
+    def terminate(self, wait: bool = True):
+        self._ce.delete_instance(self.name, wait)
+
+    def stop(self, wait: bool = True):
+        self._ce.stop_instance(self.name, wait)
diff --git a/spotty/providers/gcp/gcp_resources/snapshot.py b/spotty/providers/gcp/resources/snapshot.py
similarity index 100%
rename from spotty/providers/gcp/gcp_resources/snapshot.py
rename to spotty/providers/gcp/resources/snapshot.py
diff --git a/spotty/providers/gcp/gcp_resources/stack.py b/spotty/providers/gcp/resources/stack.py
similarity index 100%
rename from spotty/providers/gcp/gcp_resources/stack.py
rename to spotty/providers/gcp/resources/stack.py
diff --git a/spotty/providers/instance_manager_factory.py b/spotty/providers/instance_manager_factory.py
index e4b61b6..33234ea 100644
--- a/spotty/providers/instance_manager_factory.py
+++ b/spotty/providers/instance_manager_factory.py
@@ -1,11 +1,22 @@
 from importlib import import_module
 from spotty.config.project_config import ProjectConfig
-from spotty.providers.abstract_instance_manager import AbstractInstanceManager
+from spotty.deployment.abstract_instance_manager import AbstractInstanceManager
+
+
+PROVIDER_AWS = 'aws'
+PROVIDER_GCP = 'gcp'
+PROVIDER_LOCAL = 'local'
+PROVIDER_REMOTE = 'remote'
 
 
 class InstanceManagerFactory(object):
 
-    SUPPORTED_PROVIDERS = ['aws', 'gcp']
+    SUPPORTED_PROVIDERS = [
+        PROVIDER_AWS,
+        PROVIDER_GCP,
+        PROVIDER_LOCAL,
+        PROVIDER_REMOTE,
+    ]
 
     @classmethod
     def get_instance(cls, project_config: ProjectConfig, instance_config: dict) -> AbstractInstanceManager:
diff --git a/spotty/providers/local/__init__.py b/spotty/providers/local/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/spotty/providers/local/config/__init__.py b/spotty/providers/local/config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/spotty/providers/local/config/instance_config.py b/spotty/providers/local/config/instance_config.py
new file mode 100644
index 0000000..9ed6c9f
--- /dev/null
+++ b/spotty/providers/local/config/instance_config.py
@@ -0,0 +1,51 @@
+import os
+from typing import List
+from spotty.config.abstract_instance_config import AbstractInstanceConfig, VolumeMount
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.config.container_config import PROJECT_VOLUME_MOUNT_NAME
+from spotty.config.project_config import ProjectConfig
+from spotty.config.host_path_volume import HostPathVolume
+from spotty.providers.local.config.validation import validate_instance_parameters
+
+
+class InstanceConfig(AbstractInstanceConfig):
+
+    def __init__(self, instance_config: dict, project_config: ProjectConfig):
+        super().__init__(instance_config, project_config)
+
+    def _validate_instance_params(self, params: dict):
+        # validate the config and fill missing parameters with the default values
+        return validate_instance_parameters(params)
+
+    def _get_instance_volumes(self) -> List[AbstractInstanceVolume]:
+        volumes = []
+        for volume_config in self._params['volumes']:
+            volume_type = volume_config['type']
+            if volume_type == HostPathVolume.TYPE_NAME:
+                volumes.append(HostPathVolume(volume_config, self.project_config.project_name, self.name))
+            else:
+                raise ValueError('Volume type "%s" is not supported.' % volume_type)
+
+        return volumes
+
+    def _get_volume_mounts(self, volumes: List[AbstractInstanceVolume]) -> List[VolumeMount]:
+        volume_mounts = super()._get_volume_mounts(volumes)
+
+        # ignore a volume that matches the container project directory
+        volume_mounts = [volume_mount for volume_mount in volume_mounts
+                         if os.path.relpath(self.container_config.project_dir, volume_mount.mount_path) != '.']
+
+        # mount the local project directory to the container
+        volume_mounts.append(VolumeMount(
+            name=PROJECT_VOLUME_MOUNT_NAME,
+            host_path=self.project_config.project_dir,
+            mount_path=self.container_config.project_dir,
+            mode='rw',
+            hidden=True,
+        ))
+
+        return volume_mounts
+
+    @property
+    def user(self) -> str:
+        return ''
diff --git a/spotty/providers/local/config/validation.py b/spotty/providers/local/config/validation.py
new file mode 100644
index 0000000..d33b6e5
--- /dev/null
+++ b/spotty/providers/local/config/validation.py
@@ -0,0 +1,9 @@
+from spotty.config.validation import validate_config, get_instance_parameters_schema
+
+
+def validate_instance_parameters(params: dict):
+    from spotty.config.host_path_volume import HostPathVolume
+
+    schema = get_instance_parameters_schema({}, HostPathVolume.TYPE_NAME)
+
+    return validate_config(schema, params)
diff --git a/spotty/providers/local/instance_manager.py b/spotty/providers/local/instance_manager.py
new file mode 100644
index 0000000..ca47e77
--- /dev/null
+++ b/spotty/providers/local/instance_manager.py
@@ -0,0 +1,25 @@
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.errors.nothing_to_do import NothingToDoError
+from spotty.deployment.abstract_docker_instance_manager import AbstractDockerInstanceManager
+from spotty.providers.local.config.instance_config import InstanceConfig
+
+
+class InstanceManager(AbstractDockerInstanceManager):
+
+    instance_config: InstanceConfig
+
+    def _get_instance_config(self, instance_config: dict) -> InstanceConfig:
+        """Validates the instance config and returns an InstanceConfig object."""
+        return InstanceConfig(instance_config, self.project_config)
+
+    def is_running(self):
+        return True
+
+    def clean(self, output: AbstractOutputWriter):
+        pass
+
+    def sync(self, output: AbstractOutputWriter, dry_run=False):
+        raise NothingToDoError('Nothing to do. The project directory is mounted to the container.')
+
+    def download(self, download_filters: list, output: AbstractOutputWriter, dry_run=False):
+        raise NothingToDoError('Nothing to do. The project directory is mounted to the container.')
diff --git a/spotty/providers/remote/__init__.py b/spotty/providers/remote/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/spotty/providers/remote/config/__init__.py b/spotty/providers/remote/config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/spotty/providers/remote/config/instance_config.py b/spotty/providers/remote/config/instance_config.py
new file mode 100644
index 0000000..4f01b57
--- /dev/null
+++ b/spotty/providers/remote/config/instance_config.py
@@ -0,0 +1,51 @@
+import os
+from typing import List
+from spotty.config.abstract_instance_config import AbstractInstanceConfig
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
+from spotty.config.project_config import ProjectConfig
+from spotty.config.host_path_volume import HostPathVolume
+from spotty.providers.remote.config.validation import validate_instance_parameters
+
+
+class InstanceConfig(AbstractInstanceConfig):
+
+    def __init__(self, instance_config: dict, project_config: ProjectConfig):
+        super().__init__(instance_config, project_config)
+
+    def _validate_instance_params(self, params: dict):
+        # validate the config and fill missing parameters with the default values
+        return validate_instance_parameters(params)
+
+    @property
+    def user(self) -> str:
+        return self._params['user']
+
+    @property
+    def host(self) -> str:
+        return self._params['host']
+
+    @property
+    def port(self) -> int:
+        return self._params['port']
+
+    @property
+    def key_path(self) -> str:
+        key_path = os.path.expanduser(self._params['keyPath'])
+        if not os.path.isabs(key_path):
+            key_path = os.path.join(self.project_config.project_dir, key_path)
+
+        key_path = os.path.normpath(key_path)
+
+        return key_path
+
+    @property
+    def _get_instance_volumes(self) -> List[AbstractInstanceVolume]:
+        volumes = []
+        for volume_config in self._params['volumes']:
+            volume_type = volume_config['type']
+            if volume_type == HostPathVolume.TYPE_NAME:
+                volumes.append(HostPathVolume(volume_config, self.project_config.project_name, self.name))
+            else:
+                raise ValueError('Volume type "%s" is not supported.' % volume_type)
+
+        return volumes
diff --git a/spotty/providers/remote/config/validation.py b/spotty/providers/remote/config/validation.py
new file mode 100644
index 0000000..50f11c4
--- /dev/null
+++ b/spotty/providers/remote/config/validation.py
@@ -0,0 +1,17 @@
+from schema import And, Optional
+from spotty.config.validation import validate_config, get_instance_parameters_schema
+
+
+def validate_instance_parameters(params: dict):
+    from spotty.config.host_path_volume import HostPathVolume
+
+    instance_parameters = {
+        'user': str,
+        'host': str,
+        Optional('port', default=22): And(int, lambda x: 0 < x < 65536),
+        'keyPath': str,
+    }
+
+    schema = get_instance_parameters_schema(instance_parameters, HostPathVolume.TYPE_NAME)
+
+    return validate_config(schema, params)
diff --git a/spotty/providers/remote/helpers/rsync.py b/spotty/providers/remote/helpers/rsync.py
new file mode 100644
index 0000000..9a93232
--- /dev/null
+++ b/spotty/providers/remote/helpers/rsync.py
@@ -0,0 +1,71 @@
+from shutil import which
+from typing import List
+from spotty.deployment.utils.cli import shlex_join
+
+
+def check_rsync_installed():
+    """Checks that rsync is installed."""
+    if which('rsync') is None:
+        raise ValueError('rsync is not installed.')
+
+
+def get_upload_command(local_dir: str, remote_dir: str, ssh_user: str, ssh_host: str, ssh_port: int,
+                       ssh_key_path: str, filters: List[dict] = None, use_sudo: bool = False, dry_run: bool = False):
+    # make sure there is only one list of exclude filters
+    if (len(filters) > 1) or (len(filters[0]) > 1) or ('include' in filters[0]):
+        raise ValueError('At the moment "remote" provider supports only one list of exclude filters.')
+
+    remote_path = '%s@%s:%s' % (ssh_user, ssh_host, remote_dir)
+
+    return _get_rsync_command(local_dir, remote_path, ssh_port, ssh_key_path, filters, mkdir=remote_dir,
+                              use_sudo=use_sudo, dry_run=dry_run)
+
+
+def get_download_command(remote_dir: str, local_dir: str, ssh_user: str, ssh_host: str, ssh_port: int,
+                         ssh_key_path: str, filters: List[dict] = None, use_sudo: bool = False, dry_run: bool = False):
+    filters = filters[::-1]
+    remote_path = '%s@%s:%s' % (ssh_user, ssh_host, remote_dir)
+
+    return _get_rsync_command(remote_path, local_dir, ssh_port, ssh_key_path, filters, use_sudo=use_sudo,
+                              dry_run=dry_run)
+
+
+def _get_rsync_command(src_path: str, dst_path: str, ssh_port: int, ssh_key_path: str, filters: List[dict] = None,
+                       mkdir: str = None, use_sudo: bool = False, dry_run: bool = False):
+
+    sudo_str = 'sudo ' if use_sudo else ''
+    remote_rsync_cmd = sudo_str + 'rsync'
+    if mkdir:
+        remote_rsync_cmd = '%smkdir -p \'%s\' && %s' % (sudo_str, mkdir, remote_rsync_cmd)
+
+    rsync_cmd = 'rsync -av ' \
+                '--no-owner ' \
+                '--no-group ' \
+                '--prune-empty-dirs ' \
+                '-e "ssh -i \'%s\' -p %d -o StrictHostKeyChecking=no -o ConnectTimeout=10" ' \
+                '--rsync-path="%s"'  \
+                % (ssh_key_path, ssh_port, remote_rsync_cmd)
+
+    if dry_run:
+        rsync_cmd += ' --dry-run'
+
+    if filters:
+        args = []
+        for sync_filter in filters:
+            if 'exclude' in sync_filter:
+                for path in sync_filter['exclude']:
+                    args += ['--exclude', _fix_filter_path(path)]
+
+            if 'include' in sync_filter:
+                for path in sync_filter['include']:
+                    args += ['--include', _fix_filter_path(path)]
+
+        rsync_cmd += ' ' + shlex_join(args)
+
+    rsync_cmd += ' %s/ %s' % (src_path.rstrip('/'), dst_path)
+
+    return rsync_cmd
+
+
+def _fix_filter_path(path: str) -> str:
+    return '/' + path.replace('*', '**').lstrip('/')
diff --git a/spotty/providers/remote/instance_manager.py b/spotty/providers/remote/instance_manager.py
new file mode 100644
index 0000000..d312ea6
--- /dev/null
+++ b/spotty/providers/remote/instance_manager.py
@@ -0,0 +1,86 @@
+import logging
+import subprocess
+from spotty.commands.writers.abstract_output_writrer import AbstractOutputWriter
+from spotty.deployment.abstract_ssh_instance_manager import AbstractSshInstanceManager
+from spotty.providers.remote.config.instance_config import InstanceConfig
+from spotty.providers.remote.helpers.rsync import get_upload_command, check_rsync_installed, get_download_command
+
+
+class InstanceManager(AbstractSshInstanceManager):
+
+    instance_config: InstanceConfig
+
+    def _get_instance_config(self, instance_config: dict) -> InstanceConfig:
+        """Validates the instance config and returns an InstanceConfig object."""
+        return InstanceConfig(instance_config, self.project_config)
+
+    def is_running(self):
+        """Assuming the remote instance is running."""
+        return True
+
+    def clean(self, output: AbstractOutputWriter):
+        pass
+
+    def sync(self, output: AbstractOutputWriter, dry_run=False):
+
+        output.write('Syncing files with the instance...')
+
+        # check rsync is installed
+        check_rsync_installed()
+
+        # sync the project with the instance
+        rsync_cmd = get_upload_command(
+            local_dir=self.project_config.project_dir,
+            remote_dir=self.instance_config.host_project_dir,
+            ssh_user=self.ssh_user,
+            ssh_host=self.ssh_host,
+            ssh_key_path=self.ssh_key_path,
+            ssh_port=self.ssh_port,
+            filters=self.project_config.sync_filters,
+            use_sudo=(not self.instance_config.container_config.run_as_host_user),
+            dry_run=dry_run,
+        )
+
+        # execute the command locally
+        logging.debug('rsync command: ' + rsync_cmd)
+        exit_code = subprocess.call(rsync_cmd, shell=True)
+        if exit_code != 0:
+            raise ValueError('Failed to upload files to the instance.')
+
+    def download(self, download_filters: list, output: AbstractOutputWriter, dry_run=False):
+
+        output.write('Downloading files from the instance...')
+
+        # check rsync is installed
+        check_rsync_installed()
+
+        # sync the project with the instance
+        rsync_cmd = get_download_command(
+            local_dir=self.project_config.project_dir,
+            remote_dir=self.instance_config.host_project_dir,
+            ssh_user=self.ssh_user,
+            ssh_host=self.ssh_host,
+            ssh_key_path=self.ssh_key_path,
+            ssh_port=self.ssh_port,
+            filters=download_filters,
+            use_sudo=(not self.instance_config.container_config.run_as_host_user),
+            dry_run=dry_run,
+        )
+
+        # execute the command locally
+        logging.debug('rsync command: ' + rsync_cmd)
+        exit_code = subprocess.call(rsync_cmd, shell=True)
+        if exit_code != 0:
+            raise ValueError('Failed to download files from the instance.')
+
+    @property
+    def ssh_host(self) -> str:
+        return self.instance_config.host
+
+    @property
+    def ssh_key_path(self) -> str:
+        return self.instance_config.key_path
+
+    @property
+    def ssh_port(self) -> int:
+        return self.instance_config.port
diff --git a/spotty/utils.py b/spotty/utils.py
index f2d4bc7..c4c41de 100644
--- a/spotty/utils.py
+++ b/spotty/utils.py
@@ -1,6 +1,5 @@
 import os
 import random
-import re
 import string
 import errno
 
@@ -49,11 +48,3 @@ def render_table(table: list, separate_title=False):
         lines.append(title_separator if separate_title and not i else row_separator)
 
     return '\n'.join(lines)
-
-
-def fix_indents_for_lines(text: str, document: str, tag: str):
-    """Fixes indents in the multi-line text that will be inserted to a YAML document."""
-    indent_size = len(re.search(r'( *)' + tag, document).group(1))
-    text = text.replace('\n', '\n' + ' ' * indent_size)
-
-    return text


Test patch:
diff --git a/spotty/providers/test/deployment/test_volume.py b/spotty/providers/test/deployment/test_volume.py
index 896f5a3..2c36b68 100644
--- a/spotty/providers/test/deployment/test_volume.py
+++ b/spotty/providers/test/deployment/test_volume.py
@@ -1,20 +1,15 @@
-from spotty.deployment.abstract_instance_volume import AbstractInstanceVolume
+from spotty.config.abstract_instance_volume import AbstractInstanceVolume
 
 
 class TestVolume(AbstractInstanceVolume):
 
-    def __init__(self, volume_name, mount_dir):
-        self._volume_name = volume_name
-        self._mount_dir = mount_dir
+    def _validate_volume_parameters(self, params: dict) -> dict:
+        return params
 
     @property
     def title(self) -> str:
         return 'test'
 
     @property
-    def name(self) -> str:
-        return self._volume_name
-
-    @property
-    def mount_dir(self) -> str:
-        return self._mount_dir
+    def host_path(self) -> str:
+        return self._params['host_path']
diff --git a/tests/container_deployment.py b/tests/container_deployment.py
deleted file mode 100644
index 70b3502..0000000
--- a/tests/container_deployment.py
+++ /dev/null
@@ -1,83 +0,0 @@
-import unittest
-from spotty.config.container_config import ContainerConfig
-from spotty.deployment.container_deployment import ContainerDeployment, VolumeMount
-from spotty.providers.test.deployment.test_volume import TestVolume
-
-
-class TestContainerDeployment(unittest.TestCase):
-
-    def test_instance_volume(self):
-        project_name = 'test-project'
-        container_config = ContainerConfig({
-            'file': 'docker/Dockerfile',
-            'projectDir': '/workspace/project',
-            'volumeMounts': [{
-                'name': 'workspace',
-                'mountPath': '/workspace',
-            }],
-        })
-
-        volumes = [
-            TestVolume('workspace', '/mnt/test'),
-            TestVolume('docker', '/mnt/docker'),
-        ]
-
-        container = ContainerDeployment(project_name, volumes, container_config)
-
-        self.assertEqual(container.host_project_dir, '/mnt/test/project')
-        self.assertEqual(container.dockerfile_path, '/mnt/test/project/docker/Dockerfile')
-        self.assertEqual(container.docker_context_path, '/mnt/test/project/docker')
-        self.assertEqual(len(container.volume_mounts), 1)
-        self.assertEqual(container.volume_mounts[0], VolumeMount(name='workspace', host_dir='/mnt/test',
-                                                                 container_dir='/workspace'))
-
-    def test_tmp_volume(self):
-        project_name = 'test-project'
-        container_config = ContainerConfig({
-            'file': 'docker/Dockerfile',
-            'projectDir': '/workspace/project',
-            'volumeMounts': [{
-                'name': 'workspace',
-                'mountPath': '/workspace',
-            }],
-        })
-
-        volumes = [
-            TestVolume('docker', '/mnt/docker'),
-        ]
-
-        container = ContainerDeployment(project_name, volumes, container_config)
-
-        self.assertRegex(container.host_project_dir, '/tmp/spotty/container/volumes/workspace/project')
-        self.assertRegex(container.dockerfile_path, '/tmp/spotty/container/volumes/workspace/project/docker/Dockerfile')
-        self.assertRegex(container.docker_context_path, '/tmp/spotty/container/volumes/workspace/project/docker')
-        self.assertEqual(container.volume_mounts[0].name, 'workspace')
-        self.assertEqual(container.volume_mounts[0].container_dir, '/workspace')
-        self.assertRegex(container.volume_mounts[0].host_dir, '/tmp/spotty/container/volumes/workspace')
-        self.assertEqual(len(container.volume_mounts), 1)
-
-    def test_tmp_project_volume(self):
-        project_name = 'test-project'
-        container_config = ContainerConfig({
-            'file': 'docker/Dockerfile',
-            'projectDir': '/workspace/project',
-            'volumeMounts': [],
-        })
-
-        volumes = [
-            TestVolume('docker', '/mnt/docker'),
-        ]
-
-        container = ContainerDeployment(project_name, volumes, container_config)
-
-        self.assertRegex(container.host_project_dir, '/tmp/spotty/container/volumes/.project')
-        self.assertRegex(container.dockerfile_path, '/tmp/spotty/container/volumes/.project/docker/Dockerfile')
-        self.assertRegex(container.docker_context_path, '/tmp/spotty/container/volumes/.project/docker')
-        self.assertEqual(container.volume_mounts[0].name, None)
-        self.assertEqual(container.volume_mounts[0].container_dir, '/workspace/project')
-        self.assertRegex(container.volume_mounts[0].host_dir, '/tmp/spotty/container/volumes/.project')
-        self.assertEqual(len(container.volume_mounts), 1)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/providers/aws/config/instance_config_validation.py b/tests/providers/aws/config/instance_config_validation.py
index 63bdda0..29e1d64 100644
--- a/tests/providers/aws/config/instance_config_validation.py
+++ b/tests/providers/aws/config/instance_config_validation.py
@@ -13,14 +13,19 @@ def test_default_configuration(self):
 
         expected_params = {
             **required_params,
-            'amiName': None,
             'amiId': None,
+            'amiName': None,
             'availabilityZone': '',
+            'commands': '',
+            'containerName': None,
             'dockerDataRoot': '',
+            'instanceProfileArn': None,
             'localSshPort': None,
+            'managedPolicyArns': [],
             'maxPrice': 0,
-            'onDemandInstance': False,
+            'ports': [],
             'rootVolumeSize': 0,
+            'spotInstance': False,
             'subnetId': '',
             'volumes': [],
         }
diff --git a/tests/providers/aws/container_deployment.py b/tests/providers/aws/container_deployment.py
new file mode 100644
index 0000000..dd006c8
--- /dev/null
+++ b/tests/providers/aws/container_deployment.py
@@ -0,0 +1,35 @@
+import os
+import unittest
+from spotty.config.abstract_instance_config import VolumeMount
+from spotty.config.config_utils import _read_yaml
+from spotty.config.project_config import ProjectConfig
+from spotty.providers.aws.config.instance_config import InstanceConfig
+
+
+class TestContainerDeployment(unittest.TestCase):
+
+    def test_instance_volume(self):
+        local_project_dir = os.path.join(os.path.dirname(__file__), 'data', 'configs')
+        config = _read_yaml(os.path.join(local_project_dir, 'config1.yaml'))
+
+        project_config = ProjectConfig(config, local_project_dir)
+        instance_config = InstanceConfig(project_config.instances[0], project_config)
+
+        self.assertEqual(instance_config.host_project_dir, '/mnt/test/project')
+        self.assertEqual(instance_config.dockerfile_path, '/mnt/test/project/docker/Dockerfile')
+        self.assertEqual(instance_config.docker_context_path, '/mnt/test/project/docker')
+        self.assertEqual(len(instance_config.volume_mounts), 2)
+        self.assertEqual(instance_config.volume_mounts[0], VolumeMount(name='workspace',
+                                                                       host_path='/mnt/test',
+                                                                       mount_path='/workspace',
+                                                                       mode='rw',
+                                                                       hidden=False))
+        self.assertEqual(instance_config.volume_mounts[1], VolumeMount(name=None,
+                                                                       host_path='/root/.aws',
+                                                                       mount_path='/root/.aws',
+                                                                       mode='ro',
+                                                                       hidden=True,))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/providers/aws/data/configs/config1.yaml b/tests/providers/aws/data/configs/config1.yaml
new file mode 100644
index 0000000..cdbb9b0
--- /dev/null
+++ b/tests/providers/aws/data/configs/config1.yaml
@@ -0,0 +1,29 @@
+project:
+  name: my-project
+  syncFilters:
+    - exclude:
+        - .git/*
+        - .idea/*
+        - '*/__pycache__/*'
+
+container:
+  projectDir: /workspace/project
+  file: docker/Dockerfile
+  volumeMounts:
+    - name: workspace
+      mountPath: /workspace
+  commands: |
+    echo test
+
+instances:
+  - name: aws-1
+    provider: aws
+    parameters:
+      region: us-east-2
+      instanceType: t2.small
+      volumes:
+        - name: workspace
+          parameters:
+            size: 10
+            deletionPolicy: Delete
+            mountDir: /mnt/test
diff --git a/tests/providers/aws/project_resources/bucket.py b/tests/providers/aws/project_resources/bucket.py
index 4928631..2de08c8 100644
--- a/tests/providers/aws/project_resources/bucket.py
+++ b/tests/providers/aws/project_resources/bucket.py
@@ -1,7 +1,6 @@
 import unittest
-import boto3
-from spotty.commands.writers.null_output_writrer import NullOutputWriter
-from spotty.providers.aws.deployment.project_resources.bucket import BucketResource
+from spotty.deployment.abstract_cloud_instance.errors.bucket_not_found import BucketNotFoundError
+from spotty.providers.aws.resource_managers.bucket_manager import BucketManager
 from moto import mock_s3
 
 
@@ -11,25 +10,20 @@ class TestBucketResource(unittest.TestCase):
     def test_create_and_find_bucket(self):
         region = 'eu-central-1'
         project_name = 'TEST_PROJECT'
-        s3 = boto3.client('s3', region_name=region)
-        bucket_resource = BucketResource(project_name, region)
-        output = NullOutputWriter()
+        bucket_resource = BucketManager(project_name, region)
 
         # bucket not found
-        self.assertFalse(bucket_resource._find_bucket())
+        with self.assertRaises(BucketNotFoundError):
+            bucket_resource.get_bucket()
 
         # bucket found
-        bucket_name = bucket_resource.get_or_create_bucket(output)
-        self.assertEqual(bucket_name, bucket_resource._find_bucket())
-        self.assertEqual(bucket_name, bucket_resource.get_or_create_bucket(output))
+        bucket_name = bucket_resource.create_bucket().name
+        self.assertEqual(bucket_name, bucket_resource.get_bucket().name)
 
         # several buckets found
-        second_bucket_name = 'spotty-%s-111111111111-%s' % (project_name.lower(), region)
-        s3.create_bucket(Bucket=second_bucket_name)
+        bucket_resource.create_bucket()
         with self.assertRaises(ValueError):
-            bucket_resource._find_bucket()
-        with self.assertRaises(ValueError):
-            bucket_resource.get_or_create_bucket(output)
+            bucket_resource.get_bucket()
 
 
 if __name__ == '__main__':
diff --git a/tests/providers/aws/project_resources/key_pair.py b/tests/providers/aws/project_resources/key_pair.py
index 47cf8be..bb53db2 100644
--- a/tests/providers/aws/project_resources/key_pair.py
+++ b/tests/providers/aws/project_resources/key_pair.py
@@ -2,7 +2,8 @@
 import boto3
 import os
 from moto import mock_ec2
-from spotty.providers.aws.deployment.project_resources.key_pair import KeyPairResource
+from spotty.providers.aws.resource_managers.key_pair_manager import KeyPairManager
+from spotty.providers.instance_manager_factory import PROVIDER_AWS
 
 
 class TestKeyPairResource(unittest.TestCase):
@@ -10,62 +11,59 @@ class TestKeyPairResource(unittest.TestCase):
     def test_key_path(self):
         region = 'eu-central-1'
         project_name = 'TEST_PROJECT'
-        provider_name = 'aws'
-        key_resource = KeyPairResource(project_name, region, provider_name)
+        key_pair_manager = KeyPairManager(None, project_name, region)
 
         # check key path
         key_name = 'spotty-key-%s-%s' % (project_name.lower(), region)
-        key_path = os.path.join(os.path.expanduser('~'), '.spotty', 'keys', provider_name, key_name)
-        self.assertEqual(key_resource.key_path, key_path)
+        key_path = os.path.join(os.path.expanduser('~'), '.spotty', 'keys', PROVIDER_AWS, key_name)
+        self.assertEqual(key_pair_manager.key_path, key_path)
 
     @mock_ec2
     def test_create_and_delete_key(self):
         region = 'eu-central-1'
         project_name = 'TEST_PROJECT'
-        provider_name = 'aws'
         ec2 = boto3.client('ec2', region_name=region)
-        key_resource = KeyPairResource(project_name, region, provider_name)
+        key_pair_manager = KeyPairManager(ec2, project_name, region)
 
         # key doesn't exist
-        self.assertFalse(key_resource._ec2_key_exists())
+        self.assertFalse(key_pair_manager._ec2_key_exists())
 
         # create the key
-        key_name = key_resource.get_or_create_key()
-        self.assertTrue(key_resource._ec2_key_exists())
-        self.assertTrue(os.path.isfile(key_resource.key_path))
-        with open(key_resource.key_path) as f:
+        key_pair_manager.maybe_create_key()
+        self.assertTrue(key_pair_manager._ec2_key_exists())
+        self.assertTrue(os.path.isfile(key_pair_manager.key_path))
+        with open(key_pair_manager.key_path) as f:
             key_content = f.read()
 
-        # get the existing key
-        key_resource.get_or_create_key()
-        with open(key_resource.key_path) as f:
+        # make sure the key is not being recreated
+        key_pair_manager.maybe_create_key()
+        with open(key_pair_manager.key_path) as f:
             same_key_content = f.read()
-
         self.assertEqual(key_content, same_key_content)
 
         # create the key and rewrite the key file
-        ec2.delete_key_pair(KeyName=key_name)
-        self.assertFalse(key_resource._ec2_key_exists())
-        self.assertTrue(os.path.isfile(key_resource.key_path))
-        key_resource.get_or_create_key()
-        self.assertTrue(key_resource._ec2_key_exists())
-        self.assertTrue(os.path.isfile(key_resource.key_path))
-        with open(key_resource.key_path) as f:
+        ec2.delete_key_pair(KeyName=key_pair_manager.key_name)
+        self.assertFalse(key_pair_manager._ec2_key_exists())
+        self.assertTrue(os.path.isfile(key_pair_manager.key_path))
+        key_pair_manager.maybe_create_key()
+        self.assertTrue(key_pair_manager._ec2_key_exists())
+        self.assertTrue(os.path.isfile(key_pair_manager.key_path))
+        with open(key_pair_manager.key_path) as f:
             new_key_content = f.read()
 
         self.assertNotEqual(key_content, new_key_content)
 
         # recreate the key if the key file doesn't exist
-        os.unlink(key_resource.key_path)
-        self.assertFalse(os.path.isfile(key_resource.key_path))
-        key_resource.get_or_create_key()
-        self.assertTrue(key_resource._ec2_key_exists())
-        self.assertTrue(os.path.isfile(key_resource.key_path))
+        os.unlink(key_pair_manager.key_path)
+        self.assertFalse(os.path.isfile(key_pair_manager.key_path))
+        key_pair_manager.maybe_create_key()
+        self.assertTrue(key_pair_manager._ec2_key_exists())
+        self.assertTrue(os.path.isfile(key_pair_manager.key_path))
 
         # delete key
-        key_resource.delete_key()
-        self.assertFalse(key_resource._ec2_key_exists())
-        self.assertFalse(os.path.isfile(key_resource.key_path))
+        key_pair_manager.delete_key()
+        self.assertFalse(key_pair_manager._ec2_key_exists())
+        self.assertFalse(os.path.isfile(key_pair_manager.key_path))
 
 
 if __name__ == '__main__':
diff --git a/tests/providers/gcp/config/image_url.py b/tests/providers/gcp/config/image_uri.py
similarity index 60%
rename from tests/providers/gcp/config/image_url.py
rename to tests/providers/gcp/config/image_uri.py
index 56f5720..b1ccacd 100644
--- a/tests/providers/gcp/config/image_url.py
+++ b/tests/providers/gcp/config/image_uri.py
@@ -1,5 +1,5 @@
 import unittest
-from spotty.providers.gcp.config.image_url import ImageUrl
+from spotty.providers.gcp.config.image_uri import ImageUri
 
 
 class TestImageUrl(unittest.TestCase):
@@ -7,26 +7,30 @@ class TestImageUrl(unittest.TestCase):
     def test_image_url_parsing(self):
         pos_tests = [
             {
-                'url': 'projects/debian-cloud/global/images/family/debian-9',
+                'uri': 'projects/debian-cloud/global/images/family/debian-9',
                 'expected': ('debian-cloud', True, 'debian-9'),
             },
             {
-                'url': 'projects/debian-cloud/global/images/debian-9-stretch',
+                'uri': 'projects/debian-cloud/global/images/debian-9-stretch',
                 'expected': ('debian-cloud', False, 'debian-9-stretch'),
             },
             {
-                'url': 'global/images/family/my-image-family',
+                'uri': 'global/images/family/my-image-family',
                 'expected': (None, True, 'my-image-family'),
             },
             {
-                'url': 'global/images/my-custom-image',
+                'uri': 'global/images/my-custom-image',
                 'expected': (None, False, 'my-custom-image'),
-            }
+            },
+            {
+                'uri': 'https://compute.googleapis.com/compute/v1/projects/debian-cloud/global/images/debian-9-stretch',
+                'expected': ('debian-cloud', False, 'debian-9-stretch'),
+            },
         ]
 
         for pos_test in pos_tests:
-            image_url = ImageUrl(pos_test['url'])
-            self.assertEqual(pos_test['expected'], (image_url.project_id, image_url.is_family, image_url.name))
+            image_uri = ImageUri(pos_test['uri'])
+            self.assertEqual(pos_test['expected'], (image_uri.project_id, image_uri.is_family, image_uri.name))
 
         neg_tests = [
             'projects//global/images/family/debian-9',  # no project
@@ -38,8 +42,13 @@ def test_image_url_parsing(self):
             'global/images/family/debian-9/',  # ends with a slash
             'global/images/-my-custom-image',  # image name starts with a dash
             'global/images/my-custom-image-',  # image name ends with a dash
+            'https://compute.googleapis.com/compute/v1/global/images/debian-9-stretch',  # no project name
         ]
 
         for neg_test in neg_tests:
             with self.assertRaises(ValueError):
-                ImageUrl(neg_test)
+                ImageUri(neg_test)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/providers/local/__init__.py b/tests/providers/local/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/providers/local/container_deployment.py b/tests/providers/local/container_deployment.py
new file mode 100644
index 0000000..d0eb28f
--- /dev/null
+++ b/tests/providers/local/container_deployment.py
@@ -0,0 +1,35 @@
+import os
+import unittest
+from spotty.config.abstract_instance_config import VolumeMount
+from spotty.config.config_utils import _read_yaml
+from spotty.config.project_config import ProjectConfig
+from spotty.providers.local.config.instance_config import InstanceConfig
+
+
+class TestContainerDeployment(unittest.TestCase):
+
+    def test_instance_volume(self):
+        local_project_dir = os.path.join(os.path.dirname(__file__), 'data', 'configs')
+        config = _read_yaml(os.path.join(local_project_dir, 'config1.yaml'))
+
+        project_config = ProjectConfig(config, local_project_dir)
+        instance_config = InstanceConfig(project_config.instances[0], project_config)
+
+        self.assertEqual(instance_config.host_project_dir, local_project_dir)
+        self.assertEqual(instance_config.dockerfile_path, os.path.join(local_project_dir, 'docker', 'Dockerfile'))
+        self.assertEqual(instance_config.docker_context_path, os.path.join(local_project_dir, 'docker'))
+        self.assertEqual(len(instance_config.volume_mounts), 2)
+        self.assertEqual(instance_config.volume_mounts[0], VolumeMount(name='workspace',
+                                                                       host_path='/mnt/test',
+                                                                       mount_path='/workspace',
+                                                                       mode='rw',
+                                                                       hidden=False))
+        self.assertEqual(instance_config.volume_mounts[1], VolumeMount(name=None,
+                                                                       host_path=local_project_dir,
+                                                                       mount_path='/workspace/project',
+                                                                       mode='rw',
+                                                                       hidden=True))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/providers/local/data/configs/config1.yaml b/tests/providers/local/data/configs/config1.yaml
new file mode 100644
index 0000000..22ffeae
--- /dev/null
+++ b/tests/providers/local/data/configs/config1.yaml
@@ -0,0 +1,25 @@
+project:
+  name: my-project
+  syncFilters:
+    - exclude:
+        - .git/*
+        - .idea/*
+        - '*/__pycache__/*'
+
+container:
+  projectDir: /workspace/project
+  file: docker/Dockerfile
+  volumeMounts:
+    - name: workspace
+      mountPath: /workspace
+  commands: |
+    echo test
+
+instances:
+  - name: local-1
+    provider: local
+    parameters:
+      volumes:
+        - name: workspace
+          parameters:
+            path: /mnt/test