diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 01aec4f36afc0da670398d5ec11655c43a8dc5c3..6bf9dca11122c533306cd2f7a0380629da9c76e5 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -31,6 +31,7 @@ export default class Clusters {
       installHelmPath,
       installIngressPath,
       installRunnerPath,
+      installJupyterPath,
       installPrometheusPath,
       managePrometheusPath,
       clusterStatus,
@@ -51,6 +52,7 @@ export default class Clusters {
       installIngressEndpoint: installIngressPath,
       installRunnerEndpoint: installRunnerPath,
       installPrometheusEndpoint: installPrometheusPath,
+      installJupyterEndpoint: installJupyterPath,
     });
 
     this.installApplication = this.installApplication.bind(this);
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 9c12b89240c005fbb7417910d84414cbc4444b2f..e03db7b8974ba8fefb8e23053358652a3c83a6ca 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -37,6 +37,11 @@ export default {
       default: '',
     },
   },
+  data() {
+    return {
+      jupyterSuggestHostnameValue: '',
+    };
+  },
   computed: {
     generalApplicationDescription() {
       return sprintf(
@@ -121,6 +126,20 @@ export default {
         false,
       );
     },
+    jupyterInstalled() {
+      return this.applications.jupyter.status === APPLICATION_INSTALLED;
+    },
+    jupyterHostname() {
+      return this.applications.jupyter.hostname;
+    },
+    jupyterSuggestHostname() {
+      return `jupyter.${this.applications.ingress.externalIp}.xip.io`;
+    },
+  },
+  watch: {
+    jupyterSuggestHostname() {
+      this.jupyterSuggestHostnameValue = this.jupyterSuggestHostname;
+    },
   },
 };
 </script>
@@ -278,11 +297,89 @@ export default {
               applications to production.`) }}
           </div>
         </application-row>
+        <application-row
+          id="jupyter"
+          :title="applications.jupyter.title"
+          title-link="https://jupyterhub.readthedocs.io/en/stable/"
+          :status="applications.jupyter.status"
+          :status-reason="applications.jupyter.statusReason"
+          :request-status="applications.jupyter.requestStatus"
+          :request-reason="applications.jupyter.requestReason"
+        >
+          <div slot="description">
+            <p>
+              {{ s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns,
+                manages, and proxies multiple instances of the single-user
+                Jupyter notebook server. JupyterHub can be used to serve
+                notebooks to a class of students, a corporate data science group,
+                or a scientific research group.`) }}
+            </p>
+            <template v-if="jupyterInstalled">
+              <div class="form-group">
+                <label for="jupyter-hostname">
+                  {{ s__('ClusterIntegration|Jupyter Hostname') }}
+                </label>
+                <div
+                  v-if="jupyterHostname"
+                  class="input-group"
+                >
+                  <input
+                    type="text"
+                    id="jupyter-hostname"
+                    class="form-control js-hostname"
+                    :value="jupyterHostname"
+                    readonly
+                  />
+                  <span class="input-group-btn">
+                    <clipboard-button
+                      :text="jupyterHostname"
+                      :title="s__('ClusterIntegration|Copy Jupyter Hostname to clipboard')"
+                      class="js-clipboard-btn"
+                    />
+                  </span>
+                </div>
+              </div>
+            </template>
+            <template v-else-if="ingressInstalled">
+              <div class="form-group">
+                <label for="jupyter-hostname">
+                  {{ s__('ClusterIntegration|Jupyter Hostname') }}
+                </label>
+                <div class="input-group">
+                  <input
+                    type="text"
+                    id="jupyter-hostname"
+                    class="form-control js-hostname"
+                    v-model="jupyterSuggestHostnameValue"
+                  />
+                  <span class="input-group-btn">
+                    <clipboard-button
+                      :text="jupyterHostname"
+                      :title="s__('ClusterIntegration|Copy Jupyter Hostname to clipboard')"
+                      class="js-clipboard-btn"
+                    />
+                  </span>
+                </div>
+              </div>
+              <p>
+                {{ s__(`ClusterIntegration|Replace this with your own hostname if you want.
+                If you do so, point hostname to Ingress IP Address from above.`) }}
+                <a
+                  :href="ingressDnsHelpPath"
+                  target="_blank"
+                  rel="noopener noreferrer"
+                >
+                  {{ __('More information') }}
+                </a>
+              </p>
+            </template>
+          </div>
+        </application-row>
         <!--
           NOTE: Don't forget to update `clusters.scss`
           min-height for this block and uncomment `application_spec` tests
         -->
-        <!-- Add GitLab Runner row, all other plumbing is complete -->
+        <!-- Add Jupyter row, all other plumbing is complete -->
       </div>
     </div>
   </section>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index b7179f52bb347bdda1806b6b942e32f89a8d1690..371f71fde44f0121edf2014e12f43f44a1f8eccd 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -11,3 +11,4 @@ export const REQUEST_LOADING = 'request-loading';
 export const REQUEST_SUCCESS = 'request-success';
 export const REQUEST_FAILURE = 'request-failure';
 export const INGRESS = 'ingress';
+export const JUPYTER = 'jupyter';
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index 13468578f4f8125f2adf03ecd84652b4a9392291..e49db9c2f4f3c67f6f01a468692ef9877842b7bf 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -1,4 +1,5 @@
 import axios from '../../lib/utils/axios_utils';
+import { JUPYTER } from '../constants';
 
 export default class ClusterService {
   constructor(options = {}) {
@@ -8,6 +9,7 @@ export default class ClusterService {
       ingress: this.options.installIngressEndpoint,
       runner: this.options.installRunnerEndpoint,
       prometheus: this.options.installPrometheusEndpoint,
+      jupyter: this.options.installJupyterEndpoint,
     };
   }
 
@@ -16,7 +18,13 @@ export default class ClusterService {
   }
 
   installApplication(appId) {
-    return axios.post(this.appInstallEndpointMap[appId]);
+    const data = {};
+
+    if (appId === JUPYTER) {
+      data.hostname = document.getElementById('jupyter-hostname').value;
+    }
+
+    return axios.post(this.appInstallEndpointMap[appId], data);
   }
 
   static updateCluster(endpoint, data) {
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index 348bbec3b2539592e09bee0670017acd333f7f8b..f609b4251905907e8fbf6af82619817e8a1e1334 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -1,5 +1,5 @@
 import { s__ } from '../../locale';
-import { INGRESS } from '../constants';
+import { INGRESS, JUPYTER } from '../constants';
 
 export default class ClusterStore {
   constructor() {
@@ -38,6 +38,14 @@ export default class ClusterStore {
           requestStatus: null,
           requestReason: null,
         },
+        jupyter: {
+          title: s__('ClusterIntegration|JupyterHub'),
+          status: null,
+          statusReason: null,
+          requestStatus: null,
+          requestReason: null,
+          hostname: null,
+        },
       },
     };
   }
@@ -83,6 +91,8 @@ export default class ClusterStore {
 
       if (appId === INGRESS) {
         this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
+      } else if (appId === JUPYTER) {
+        this.state.applications.jupyter.hostname = serverAppEntry.hostname;
       }
     });
   }
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 3fd130781310198904c89f2de71fc83576fe949d..cfcce91f514b8b01da621364dc288f02fb0ab2f1 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -6,7 +6,7 @@
 
 .cluster-applications-table {
   // Wait for the Vue to kick-in and render the applications block
-  min-height: 400px;
+  min-height: 500px;
 }
 
 .clusters-dropdown-menu {
diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb
index 35885543622535aaf9c365267a3451713ed89add..9198a66b73dc46e25a9249b789146b4163bf71a8 100644
--- a/app/controllers/projects/clusters/applications_controller.rb
+++ b/app/controllers/projects/clusters/applications_controller.rb
@@ -6,6 +6,7 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll
 
   def create
     application = @application_class.find_or_create_by!(cluster: @cluster)
+    application.update(hostname: params[:hostname]) if application.respond_to?(:hostname)
 
     Clusters::Applications::ScheduleInstallationService.new(project, current_user).execute(application)
 
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index ec75c120dac7c04aea35de17413def46d472122c..ef62be34abdabf0329bb5e95118aed888447e8f7 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -12,17 +12,39 @@ class Jupyter < ActiveRecord::Base
       default_value_for :version, VERSION
 
       def chart
-        # TODO: publish jupyterhub charts that we can use for our installation
-        # and provide path to it here.
+        "#{name}/jupyterhub"
+      end
+
+      def repository
+        'https://jupyterhub.github.io/helm-chart/'
+      end
+
+      def values
+        content_values.to_yaml
       end
 
       def install_command
         Gitlab::Kubernetes::Helm::InstallCommand.new(
           name,
           chart: chart,
-          values: values
+          values: values,
+          repository: repository
         )
       end
+
+      private
+
+      def specification
+        {
+          "ingress" => { "hosts" => [hostname] },
+          "hub" => { "cookieSecret" => SecureRandom.hex(32) },
+          "proxy" => { "secretToken" => SecureRandom.hex(32) }
+        }
+      end
+
+      def content_values
+        YAML.load_file(chart_values_file).deep_merge!(specification)
+      end
     end
   end
 end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 92e5da770661c2f53a27a3897d9dded699bb0961..d99f858e0c09ed87e10b22b25c8de3f8afe949ba 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -27,6 +27,7 @@ class Cluster < ActiveRecord::Base
     has_one :application_ingress, class_name: 'Clusters::Applications::Ingress'
     has_one :application_prometheus, class_name: 'Clusters::Applications::Prometheus'
     has_one :application_runner, class_name: 'Clusters::Applications::Runner'
+    has_one :application_jupyter, class_name: 'Clusters::Applications::Jupyter'
 
     accepts_nested_attributes_for :provider_gcp, update_only: true
     accepts_nested_attributes_for :platform_kubernetes, update_only: true
@@ -75,7 +76,8 @@ def applications
         application_helm || build_application_helm,
         application_ingress || build_application_ingress,
         application_prometheus || build_application_prometheus,
-        application_runner || build_application_runner
+        application_runner || build_application_runner,
+        application_jupyter || build_application_jupyter
       ]
     end
 
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
index b22a0b666ef56ec8fd2c0129439af85c8b7c60dd..77fc3336521af9dfa1c6aec37b674f9f1bfa0732 100644
--- a/app/serializers/cluster_application_entity.rb
+++ b/app/serializers/cluster_application_entity.rb
@@ -3,4 +3,5 @@ class ClusterApplicationEntity < Grape::Entity
   expose :status_name, as: :status
   expose :status_reason
   expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
+  expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) }
 end
diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb
index 4c25a09814bcccc51e0153f4a552e0e426580d70..7ec3a9baa6e9859b2ff83ed9cda6f7a6774a9788 100644
--- a/app/services/clusters/applications/install_service.rb
+++ b/app/services/clusters/applications/install_service.rb
@@ -12,8 +12,8 @@ def execute
             ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
         rescue Kubeclient::HttpError => ke
           app.make_errored!("Kubernetes error: #{ke.message}")
-        rescue StandardError
-          app.make_errored!("Can't start installation process")
+        rescue StandardError => e
+          app.make_errored!("Can't start installation process. #{e.message}")
         end
       end
     end
diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml
index 4c510293204aa66b81cea241663c28bfdd5f166b..08d2deff6f8cf516b9595d1df5a541bdd00d50ef 100644
--- a/app/views/projects/clusters/show.html.haml
+++ b/app/views/projects/clusters/show.html.haml
@@ -11,6 +11,7 @@
   install_ingress_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :ingress),
   install_prometheus_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :prometheus),
   install_runner_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :runner),
+  install_jupyter_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :jupyter),
   toggle_status: @cluster.enabled? ? 'true': 'false',
   cluster_status: @cluster.status_name,
   cluster_status_reason: @cluster.status_reason,
diff --git a/db/migrate/20180511131058_create_clusters_applications_jupyter.rb b/db/migrate/20180511131058_create_clusters_applications_jupyter.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5fd39f24d984aad228f672b3201086cee211b664
--- /dev/null
+++ b/db/migrate/20180511131058_create_clusters_applications_jupyter.rb
@@ -0,0 +1,22 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CreateClustersApplicationsJupyter < ActiveRecord::Migration
+  include Gitlab::Database::MigrationHelpers
+
+  DOWNTIME = false
+
+  def change
+    create_table :clusters_applications_jupyters do |t|
+      t.references :cluster, null: false, unique: true, foreign_key: { on_delete: :cascade }
+
+      t.integer :status, null: false
+      t.string :version, null: false
+      t.string :hostname
+
+      t.text :status_reason
+
+      t.timestamps_with_timezone null: false
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 37d336b9928fc3f1e527ffeed193d6cbb50d9532..3a57f9ecbd2fd8580381e5445a4952937b6fbe76 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -135,16 +135,13 @@
     t.boolean "clientside_sentry_enabled", default: false, null: false
     t.string "clientside_sentry_dsn"
     t.boolean "prometheus_metrics_enabled", default: true, null: false
+    t.boolean "authorized_keys_enabled", default: true, null: false
     t.boolean "help_page_hide_commercial_content", default: false
     t.string "help_page_support_url"
     t.integer "performance_bar_allowed_group_id"
     t.boolean "hashed_storage_enabled", default: false, null: false
     t.boolean "project_export_enabled", default: true, null: false
     t.boolean "auto_devops_enabled", default: false, null: false
-    t.integer "circuitbreaker_failure_count_threshold", default: 3
-    t.integer "circuitbreaker_failure_reset_time", default: 1800
-    t.integer "circuitbreaker_storage_timeout", default: 15
-    t.integer "circuitbreaker_access_retries", default: 3
     t.boolean "throttle_unauthenticated_enabled", default: false, null: false
     t.integer "throttle_unauthenticated_requests_per_period", default: 3600, null: false
     t.integer "throttle_unauthenticated_period_in_seconds", default: 3600, null: false
@@ -154,13 +151,16 @@
     t.boolean "throttle_authenticated_web_enabled", default: false, null: false
     t.integer "throttle_authenticated_web_requests_per_period", default: 7200, null: false
     t.integer "throttle_authenticated_web_period_in_seconds", default: 3600, null: false
-    t.integer "circuitbreaker_check_interval", default: 1, null: false
-    t.boolean "password_authentication_enabled_for_web"
-    t.boolean "password_authentication_enabled_for_git", default: true
+    t.integer "circuitbreaker_failure_count_threshold", default: 3
+    t.integer "circuitbreaker_failure_reset_time", default: 1800
+    t.integer "circuitbreaker_storage_timeout", default: 15
+    t.integer "circuitbreaker_access_retries", default: 3
     t.integer "gitaly_timeout_default", default: 55, null: false
     t.integer "gitaly_timeout_medium", default: 30, null: false
     t.integer "gitaly_timeout_fast", default: 10, null: false
-    t.boolean "authorized_keys_enabled", default: true, null: false
+    t.boolean "password_authentication_enabled_for_web"
+    t.boolean "password_authentication_enabled_for_git", default: true, null: false
+    t.integer "circuitbreaker_check_interval", default: 1, null: false
     t.string "auto_devops_domain"
     t.boolean "pages_domain_verification_enabled", default: true, null: false
     t.boolean "allow_local_requests_from_hooks_and_services", default: false, null: false
@@ -375,12 +375,12 @@
     t.integer "project_id", null: false
     t.integer "job_id", null: false
     t.integer "file_type", null: false
-    t.integer "file_store"
     t.integer "size", limit: 8
     t.datetime_with_timezone "created_at", null: false
     t.datetime_with_timezone "updated_at", null: false
     t.datetime_with_timezone "expire_at"
     t.string "file"
+    t.integer "file_store"
     t.binary "file_sha256"
   end
 
@@ -448,8 +448,8 @@
     t.integer "auto_canceled_by_id"
     t.integer "pipeline_schedule_id"
     t.integer "source"
-    t.integer "config_source"
     t.boolean "protected"
+    t.integer "config_source"
     t.integer "failure_reason"
   end
 
@@ -495,8 +495,8 @@
     t.boolean "run_untagged", default: true, null: false
     t.boolean "locked", default: false, null: false
     t.integer "access_level", default: 0, null: false
-    t.string "ip_address"
     t.integer "maximum_timeout"
+    t.string "ip_address"
     t.integer "runner_type", limit: 2, null: false
   end
 
@@ -635,6 +635,16 @@
     t.string "external_ip"
   end
 
+  create_table "clusters_applications_jupyters", force: :cascade do |t|
+    t.integer "cluster_id", null: false
+    t.integer "status", null: false
+    t.string "version", null: false
+    t.string "hostname"
+    t.text "status_reason"
+    t.datetime_with_timezone "created_at", null: false
+    t.datetime_with_timezone "updated_at", null: false
+  end
+
   create_table "clusters_applications_prometheus", force: :cascade do |t|
     t.integer "cluster_id", null: false
     t.integer "status", null: false
@@ -904,8 +914,8 @@
   add_index "gpg_signatures", ["project_id"], name: "index_gpg_signatures_on_project_id", using: :btree
 
   create_table "group_custom_attributes", force: :cascade do |t|
-    t.datetime "created_at", null: false
-    t.datetime "updated_at", null: false
+    t.datetime_with_timezone "created_at", null: false
+    t.datetime_with_timezone "updated_at", null: false
     t.integer "group_id", null: false
     t.string "key", null: false
     t.string "value", null: false
@@ -987,6 +997,7 @@
   add_index "issues", ["moved_to_id"], name: "index_issues_on_moved_to_id", where: "(moved_to_id IS NOT NULL)", using: :btree
   add_index "issues", ["project_id", "created_at", "id", "state"], name: "index_issues_on_project_id_and_created_at_and_id_and_state", using: :btree
   add_index "issues", ["project_id", "due_date", "id", "state"], name: "idx_issues_on_project_id_and_due_date_and_id_and_state_partial", where: "(due_date IS NOT NULL)", using: :btree
+  add_index "issues", ["project_id", "due_date", "id", "state"], name: "index_issues_on_project_id_and_due_date_and_id_and_state", using: :btree
   add_index "issues", ["project_id", "iid"], name: "index_issues_on_project_id_and_iid", unique: true, using: :btree
   add_index "issues", ["project_id", "updated_at", "id", "state"], name: "index_issues_on_project_id_and_updated_at_and_id_and_state", using: :btree
   add_index "issues", ["relative_position"], name: "index_issues_on_relative_position", using: :btree
@@ -1203,6 +1214,7 @@
     t.boolean "merge_when_pipeline_succeeds", default: false, null: false
     t.integer "merge_user_id"
     t.string "merge_commit_sha"
+    t.string "rebase_commit_sha"
     t.string "in_progress_merge_commit_sha"
     t.integer "lock_version"
     t.text "title_html"
@@ -1215,7 +1227,6 @@
     t.string "merge_jid"
     t.boolean "discussion_locked"
     t.integer "latest_merge_request_diff_id"
-    t.string "rebase_commit_sha"
     t.boolean "allow_maintainer_to_push"
   end
 
@@ -1475,8 +1486,8 @@
   add_index "project_ci_cd_settings", ["project_id"], name: "index_project_ci_cd_settings_on_project_id", unique: true, using: :btree
 
   create_table "project_custom_attributes", force: :cascade do |t|
-    t.datetime "created_at", null: false
-    t.datetime "updated_at", null: false
+    t.datetime_with_timezone "created_at", null: false
+    t.datetime_with_timezone "updated_at", null: false
     t.integer "project_id", null: false
     t.string "key", null: false
     t.string "value", null: false
@@ -1568,8 +1579,10 @@
     t.string "avatar"
     t.string "import_status"
     t.integer "star_count", default: 0, null: false
+    t.boolean "merge_requests_rebase_enabled", default: false, null: false
     t.string "import_type"
     t.string "import_source"
+    t.boolean "merge_requests_ff_only_enabled", default: false, null: false
     t.text "import_error"
     t.integer "ci_id"
     t.boolean "shared_runners_enabled", default: true, null: false
@@ -1585,6 +1598,7 @@
     t.boolean "only_allow_merge_if_pipeline_succeeds", default: false, null: false
     t.boolean "has_external_issue_tracker"
     t.string "repository_storage", default: "default", null: false
+    t.boolean "repository_read_only"
     t.boolean "request_access_enabled", default: false, null: false
     t.boolean "has_external_wiki"
     t.string "ci_config_path"
@@ -1599,9 +1613,6 @@
     t.datetime "last_repository_updated_at"
     t.integer "storage_version", limit: 2
     t.boolean "resolve_outdated_diff_discussions"
-    t.boolean "repository_read_only"
-    t.boolean "merge_requests_ff_only_enabled", default: false
-    t.boolean "merge_requests_rebase_enabled", default: false, null: false
     t.integer "jobs_cache_index"
     t.boolean "pages_https_only", default: true
     t.boolean "remote_mirror_available_overridden"
@@ -1945,9 +1956,9 @@
     t.string "model_type"
     t.string "uploader", null: false
     t.datetime "created_at", null: false
+    t.integer "store"
     t.string "mount_point"
     t.string "secret"
-    t.integer "store"
   end
 
   add_index "uploads", ["checksum"], name: "index_uploads_on_checksum", using: :btree
@@ -2179,8 +2190,9 @@
   add_foreign_key "cluster_providers_gcp", "clusters", on_delete: :cascade
   add_foreign_key "clusters", "users", on_delete: :nullify
   add_foreign_key "clusters_applications_helm", "clusters", on_delete: :cascade
-  add_foreign_key "clusters_applications_ingress", "clusters", name: "fk_753a7b41c1", on_delete: :cascade
-  add_foreign_key "clusters_applications_prometheus", "clusters", name: "fk_557e773639", on_delete: :cascade
+  add_foreign_key "clusters_applications_ingress", "clusters", on_delete: :cascade
+  add_foreign_key "clusters_applications_jupyters", "clusters", on_delete: :cascade
+  add_foreign_key "clusters_applications_prometheus", "clusters", on_delete: :cascade
   add_foreign_key "clusters_applications_runners", "ci_runners", column: "runner_id", name: "fk_02de2ded36", on_delete: :nullify
   add_foreign_key "clusters_applications_runners", "clusters", on_delete: :cascade
   add_foreign_key "container_repositories", "projects"
@@ -2285,8 +2297,8 @@
   add_foreign_key "u2f_registrations", "users"
   add_foreign_key "user_callouts", "users", on_delete: :cascade
   add_foreign_key "user_custom_attributes", "users", on_delete: :cascade
-  add_foreign_key "user_interacted_projects", "projects", name: "fk_722ceba4f7", on_delete: :cascade
-  add_foreign_key "user_interacted_projects", "users", name: "fk_0894651f08", on_delete: :cascade
+  add_foreign_key "user_interacted_projects", "projects", on_delete: :cascade
+  add_foreign_key "user_interacted_projects", "users", on_delete: :cascade
   add_foreign_key "user_synced_attributes_metadata", "users", on_delete: :cascade
   add_foreign_key "users", "application_setting_terms", column: "accepted_term_id", name: "fk_789cd90b35", on_delete: :cascade
   add_foreign_key "users_star_projects", "projects", name: "fk_22cd27ddfc", on_delete: :cascade
diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb
index 3deca103578e9a693fd4710b51eaf0f06c6442e7..4600d17abb18286e1b20d4e483fec3f5a57bfc5e 100644
--- a/spec/factories/clusters/applications/helm.rb
+++ b/spec/factories/clusters/applications/helm.rb
@@ -35,5 +35,6 @@
     factory :clusters_applications_ingress, class: Clusters::Applications::Ingress
     factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus
     factory :clusters_applications_runner, class: Clusters::Applications::Runner
+    factory :clusters_applications_jupyter, class: Clusters::Applications::Jupyter
   end
 end
diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json
index d27c12e43f285e208c959131dde6371a73eb699f..ccef17a6615838c2b9b8c0e28a5d41f49bd23a9f 100644
--- a/spec/fixtures/api/schemas/cluster_status.json
+++ b/spec/fixtures/api/schemas/cluster_status.json
@@ -31,7 +31,8 @@
           }
         },
         "status_reason": { "type": ["string", "null"] },
-        "external_ip": { "type": ["string", "null"] }
+        "external_ip": { "type": ["string", "null"] },
+        "hostname": { "type": ["string", "null"] }
       },
       "required" : [ "name", "status" ]
     }
diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb
index b942554d67b3a13f833a7e04ee4be7b924d1bd9f..6f66515b45fd74f874544a6afa171966aebd0414 100644
--- a/spec/models/clusters/cluster_spec.rb
+++ b/spec/models/clusters/cluster_spec.rb
@@ -234,9 +234,10 @@
       let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) }
       let!(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) }
       let!(:runner) { create(:clusters_applications_runner, cluster: cluster) }
+      let!(:jupyter) { create(:clusters_applications_jupyter, cluster: cluster) }
 
       it 'returns a list of created applications' do
-        is_expected.to contain_exactly(helm, ingress, prometheus, runner)
+        is_expected.to contain_exactly(helm, ingress, prometheus, runner, jupyter)
       end
     end
   end
diff --git a/vendor/jupyter/values.yaml b/vendor/jupyter/values.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f9455f9098664986bf4e668a001dcd25e242719e
--- /dev/null
+++ b/vendor/jupyter/values.yaml
@@ -0,0 +1,16 @@
+rbac:
+  enabled: false
+
+hub:
+  extraEnv:
+    JUPYTER_ENABLE_LAB: 1
+  extraConfig: |
+    c.KubeSpawner.cmd = ['jupyter-labhub']
+
+singleuser:
+  defaultUrl: "/lab"
+
+ingress:
+ enabled: true
+ annotations:
+   kubernetes.io/ingress.class: "nginx"