diff --git a/Gemfile b/Gemfile
index 2cc7764e6b8f048d3338790e3611e3bb14994ae6..17e628ceea68b003f843f17acf29d34876c0704f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -178,6 +178,9 @@ gem 'asana', '~> 0.4.0'
 # FogBugz integration
 gem 'ruby-fogbugz', '~> 0.2.1'
 
+# Kubernetes integration
+gem 'kubeclient', '~> 2.2.0'
+
 # d3
 gem 'd3_rails', '~> 3.5.0'
 
diff --git a/Gemfile.lock b/Gemfile.lock
index 3de1a7cbf262178febf56fcfbfaee170cd7cd9a3..7269b528e3030a22d67fc59582cd130e9ee9b022 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -161,6 +161,8 @@ GEM
     diff-lcs (1.2.5)
     diffy (3.1.0)
     docile (1.1.5)
+    domain_name (0.5.20161021)
+      unf (>= 0.0.5, < 1.0.0)
     doorkeeper (4.2.0)
       railties (>= 4.2)
     dropzonejs-rails (0.7.2)
@@ -318,6 +320,15 @@ GEM
     html2text (0.2.0)
       nokogiri (~> 1.6)
     htmlentities (4.3.4)
+    http (0.9.8)
+      addressable (~> 2.3)
+      http-cookie (~> 1.0)
+      http-form_data (~> 1.0.1)
+      http_parser.rb (~> 0.6.0)
+    http-cookie (1.0.3)
+      domain_name (~> 0.5)
+    http-form_data (1.0.1)
+    http_parser.rb (0.6.0)
     httparty (0.13.7)
       json (~> 1.8)
       multi_xml (>= 0.5.2)
@@ -352,6 +363,10 @@ GEM
     knapsack (1.11.0)
       rake
       timecop (>= 0.1.0)
+    kubeclient (2.2.0)
+      http (= 0.9.8)
+      recursive-open-struct (= 1.0.0)
+      rest-client
     launchy (2.4.3)
       addressable (~> 2.3)
     letter_opener (1.4.1)
@@ -388,6 +403,7 @@ GEM
     mysql2 (0.3.20)
     net-ldap (0.12.1)
     net-ssh (3.0.1)
+    netrc (0.11.0)
     newrelic_rpm (3.16.0.318)
     nokogiri (1.6.8)
       mini_portile2 (~> 2.1.0)
@@ -543,6 +559,7 @@ GEM
       json (~> 1.4)
     recaptcha (3.0.0)
       json
+    recursive-open-struct (1.0.0)
     redcarpet (3.3.3)
     redis (3.2.2)
     redis-actionpack (5.0.1)
@@ -568,6 +585,10 @@ GEM
       listen (~> 3.0)
     responders (2.3.0)
       railties (>= 4.2.0, < 5.1)
+    rest-client (2.0.0)
+      http-cookie (>= 1.0.2, < 2.0)
+      mime-types (>= 1.16, < 4.0)
+      netrc (~> 0.8)
     rinku (2.0.0)
     rotp (2.1.2)
     rouge (2.0.7)
@@ -859,6 +880,7 @@ DEPENDENCIES
   jwt
   kaminari (~> 0.17.0)
   knapsack (~> 1.11.0)
+  kubeclient (~> 2.2.0)
   letter_opener_web (~> 1.3.0)
   license_finder (~> 2.1.0)
   licensee (~> 8.0.0)
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index c33d7eecb9f7941ba7f8d29d9436ee1abc652480..549a8526715a11d849167282b85916c13cc2d080 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -18,7 +18,7 @@ module ServiceParams
                     :add_pusher, :send_from_committer_email, :disable_diffs,
                     :external_wiki_url, :notify, :color,
                     :server_host, :server_port, :default_irc_uri, :enable_ssl_verification,
-                    :jira_issue_transition_id, :url, :project_key]
+                    :jira_issue_transition_id, :url, :project_key, :ca_pem, :namespace]
 
   # Parameters to ignore if no value is specified
   FILTER_BLANK_PARAMS = [:password]
diff --git a/app/models/project.rb b/app/models/project.rb
index 77d740081c6cb967042b8d8fa8235a009c7d18d0..2c726cfc5df7a377b269d63b6c338f5eb1f6d23c 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -106,6 +106,7 @@ class Project < ActiveRecord::Base
   has_one :bugzilla_service, dependent: :destroy
   has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project
   has_one :external_wiki_service, dependent: :destroy
+  has_one :kubernetes_service, dependent: :destroy, inverse_of: :project
 
   has_one  :forked_project_link,  dependent: :destroy, foreign_key: "forked_to_project_id"
   has_one  :forked_from_project,  through:   :forked_project_link
@@ -742,6 +743,14 @@ class Project < ActiveRecord::Base
     @ci_service ||= ci_services.reorder(nil).find_by(active: true)
   end
 
+  def deployment_services
+    services.where(category: :deployment)
+  end
+
+  def deployment_service
+    @deployment_service ||= deployment_services.reorder(nil).find_by(active: true)
+  end
+
   def jira_tracker?
     issues_tracker.to_param == 'jira'
   end
diff --git a/app/models/project_services/deployment_service.rb b/app/models/project_services/deployment_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..55e98c31251921f192e4c9c83485f1c6df0b438e
--- /dev/null
+++ b/app/models/project_services/deployment_service.rb
@@ -0,0 +1,11 @@
+# Base class for deployment services
+#
+# These services integrate with a deployment solution like Kubernetes/OpenShift,
+# Mesosphere, etc, to provide additional features to environments.
+class DeploymentService < Service
+  default_value_for :category, 'deployment'
+
+  def supported_events
+    []
+  end
+end
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..80ae1191108bb733a36540bc5b83cd7da524051f
--- /dev/null
+++ b/app/models/project_services/kubernetes_service.rb
@@ -0,0 +1,118 @@
+class KubernetesService < DeploymentService
+  # Namespace defaults to the project path, but can be overridden in case that
+  # is an invalid or inappropriate name
+  prop_accessor :namespace
+
+  #  Access to kubernetes is directly through the API
+  prop_accessor :api_url
+
+  # Bearer authentication
+  # TODO:  user/password auth, client certificates
+  prop_accessor :token
+
+  # Provide a custom CA bundle for self-signed deployments
+  prop_accessor :ca_pem
+
+  with_options presence: true, if: :activated? do
+    validates :api_url, url: true
+    validates :token
+
+    validates :namespace,
+      format: {
+        with: Gitlab::Regex.kubernetes_namespace_regex,
+        message: Gitlab::Regex.kubernetes_namespace_regex_message,
+      },
+      length: 1..63
+  end
+
+  def initialize_properties
+    if properties.nil?
+      self.properties = {}
+      self.namespace = project.path if project.present?
+    end
+  end
+
+  def title
+    'Kubernetes'
+  end
+
+  def description
+    'Kubernetes / Openshift integration'
+  end
+
+  def help
+    ''
+  end
+
+  def to_param
+    'kubernetes'
+  end
+
+  def fields
+    [
+        { type: 'text',
+          name: 'namespace',
+          title: 'Kubernetes namespace',
+          placeholder: 'Kubernetes namespace',
+        },
+        { type: 'text',
+          name: 'api_url',
+          title: 'API URL',
+          placeholder: 'Kubernetes API URL, like https://kube.example.com/',
+        },
+        { type: 'text',
+          name: 'token',
+          title: 'Service token',
+          placeholder: 'Service token',
+        },
+        { type: 'textarea',
+          name: 'ca_pem',
+          title: 'Custom CA bundle',
+          placeholder: 'Certificate Authority bundle (PEM format)',
+        },
+    ]
+  end
+
+  # Check we can connect to the Kubernetes API
+  def test(*args)
+    kubeclient = build_kubeclient
+    kubeclient.discover
+
+    { success: kubeclient.discovered, result: "Checked API discovery endpoint" }
+  rescue => err
+    { success: false, result: err }
+  end
+
+  private
+
+  def build_kubeclient(api_path = '/api', api_version = 'v1')
+    return nil unless api_url && namespace && token
+
+    url = URI.parse(api_url)
+    url.path = url.path[0..-2] if url.path[-1] == "/"
+    url.path += api_path
+
+    ::Kubeclient::Client.new(
+      url,
+      api_version,
+      ssl_options: kubeclient_ssl_options,
+      auth_options: kubeclient_auth_options,
+      http_proxy_uri: ENV['http_proxy']
+    )
+  end
+
+  def kubeclient_ssl_options
+    opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER }
+
+    if ca_pem.present?
+      opts[:cert_store] = OpenSSL::X509::Store.new
+      opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem))
+    end
+
+    opts
+  end
+
+  def kubeclient_auth_options
+    { bearer_token: token }
+  end
+end
diff --git a/app/models/service.rb b/app/models/service.rb
index 0c36acfc1b72bc259dfac81690089959071fbec8..e49a8fa29040fa6d61c895bc10eab5192e9ed1d6 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -214,6 +214,7 @@ class Service < ActiveRecord::Base
       hipchat
       irker
       jira
+      kubernetes
       mattermost_slash_commands
       pipelines_email
       pivotaltracker
diff --git a/changelogs/unreleased/22864-kubernetes-service.yml b/changelogs/unreleased/22864-kubernetes-service.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ea1323cbeb02966c918a2f5d53bef96485397fbb
--- /dev/null
+++ b/changelogs/unreleased/22864-kubernetes-service.yml
@@ -0,0 +1,4 @@
+---
+title: Introduce deployment services, starting with a KubernetesService
+merge_request: 7994
+author: 
diff --git a/doc/project_services/img/kubernetes_configuration.png b/doc/project_services/img/kubernetes_configuration.png
new file mode 100644
index 0000000000000000000000000000000000000000..349a2dc8456e405b129611a53aacc8af41e59d6c
Binary files /dev/null and b/doc/project_services/img/kubernetes_configuration.png differ
diff --git a/doc/project_services/kubernetes.md b/doc/project_services/kubernetes.md
new file mode 100644
index 0000000000000000000000000000000000000000..cb577b608b4aa8f54bea1d88ae8df247e697b3a8
--- /dev/null
+++ b/doc/project_services/kubernetes.md
@@ -0,0 +1,38 @@
+# GitLab Kubernetes / OpenShift integration
+
+GitLab can be configured to interact with Kubernetes, or other systems using the
+Kubernetes API (such as OpenShift).
+
+Each project can be configured to connect to a different Kubernetes cluster, see
+the [configuration](#configuration) section.
+
+If you have a single cluster that you want to use for all your projects,
+you can pre-fill the settings page with a default template. To configure the
+template, see the [Services Templates](services-templates.md) document.
+
+## Configuration
+
+![Kubernetes configuration settings](img/kubernetes_configuration.png)
+
+The Kubernetes service takes the following arguments:
+
+1. Kubernetes namespace
+1. API URL
+1. Service token
+1. Custom CA bundle
+
+The API URL is the URL that GitLab uses to access the Kubernetes API. Kubernetes
+exposes several APIs - we want the "base" URL that is common to all of them,
+e.g., `https://kubernetes.example.com` rather than `https://kubernetes.example.com/api/v1`.
+
+GitLab authenticates against Kubernetes using service tokens, which are
+scoped to a particular `namespace`. If you don't have a service token yet,
+you can follow the
+[Kubernetes documentation](http://kubernetes.io/docs/user-guide/service-accounts/)
+to create one. You can also view or create service tokens in the
+[Kubernetes dashboard](http://kubernetes.io/docs/user-guide/ui/) - visit
+`Config -> Secrets`.
+
+Fill in the service token and namespace according to the values you just got.
+If the API is using a self-signed TLS certificate, you'll also need to include
+the `ca.crt` contents as the `Custom CA bundle`.
diff --git a/doc/project_services/project_services.md b/doc/project_services/project_services.md
index 890f7525b0e745da9f5d48d3e2b2583577bdbbba..a7bcd186a8c8356792283699282c40777c0b5d28 100644
--- a/doc/project_services/project_services.md
+++ b/doc/project_services/project_services.md
@@ -42,6 +42,7 @@ further configuration instructions and details. Contributions are welcome.
 | [Irker (IRC gateway)](irker.md) | Send IRC messages, on update, to a list of recipients through an Irker gateway |
 | [JIRA](jira.md) | JIRA issue tracker |
 | JetBrains TeamCity CI | A continuous integration and build server |
+| [Kubernetes](kubernetes.md) | A containerized deployment service |
 | [Mattermost slash commands](mattermost_slash_commands.md) | Mattermost chat and ChatOps slash commands |
 | PivotalTracker | Project Management Software (Source Commits Endpoint) |
 | Pushover | Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop |
diff --git a/lib/api/services.rb b/lib/api/services.rb
index fde2e2746f1bb1cc12fc1982d3de9049e447c5ba..b1e072b4f470e4243a4e79c91aa8936f73d694ad 100644
--- a/lib/api/services.rb
+++ b/lib/api/services.rb
@@ -351,6 +351,34 @@ module API
           desc: 'The ID of a transition that moves issues to a closed state. You can find this number under the JIRA workflow administration (**Administration > Issues > Workflows**) by selecting **View** under **Operations** of the desired workflow of your project. The ID of each state can be found inside the parenthesis of each transition name under the **Transitions (id)** column ([see screenshot][trans]). By default, this ID is set to `2`'
         }
       ],
+
+      'kubernetes' => [
+        {
+          required: true,
+          name: :namespace,
+          type: String,
+          desc: 'The Kubernetes namespace to use'
+        },
+        {
+          required: true,
+          name: :api_url,
+          type: String,
+          desc: 'The URL to the Kubernetes cluster API, e.g., https://kubernetes.example.com'
+        },
+        {
+          required: true,
+          name: :token,
+          type: String,
+          desc: 'The service token to authenticate against the Kubernetes cluster with'
+        },
+        {
+          required: false,
+          name: :ca_pem,
+          type: String,
+          desc: 'A custom certificate authority bundle to verify the Kubernetes cluster with (PEM format)'
+        },
+      ],
+
       'mattermost-slash-commands' => [
         {
           required: true,
diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb
index d9d1e3cccca858ea5960e998b2f44d7253e020de..7c711d581e8bdaeab91f03c0f8a5768e84898b5f 100644
--- a/lib/gitlab/regex.rb
+++ b/lib/gitlab/regex.rb
@@ -123,5 +123,13 @@ module Gitlab
     def environment_name_regex_message
       "can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.' and spaces"
     end
+
+    def kubernetes_namespace_regex
+      /\A[a-z0-9]([-a-z0-9]*[a-z0-9])?\z/
+    end
+
+    def kubernetes_namespace_regex_message
+      "can contain only letters, digits or '-', and cannot start or end with '-'"
+    end
   end
 end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 1166498ddffb165c6806ea3982f695014dd968da..0d072d6a690f7d74d2262c09338ac5258b1c96d0 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -133,4 +133,17 @@ FactoryGirl.define do
       )
     end
   end
+
+  factory :kubernetes_project, parent: :empty_project do
+    after :create do |project|
+      project.create_kubernetes_service(
+        active: true,
+        properties: {
+          namespace: project.path,
+          api_url: 'https://kubernetes.example.com/api',
+          token: 'a' * 40,
+        }
+      )
+    end
+  end
 end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 8e1a28f272346c1c2ed3275c4eba32ad4a8c8ab8..c4ee838b7c9b69ab6a39138c9a89e53886a55e81 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -147,6 +147,7 @@ project:
 - bugzilla_service
 - gitlab_issue_tracker_service
 - external_wiki_service
+- kubernetes_service
 - forked_project_link
 - forked_from_project
 - forked_project_links
diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ffb92012b89851da6e91657f2b26efe1a990a13e
--- /dev/null
+++ b/spec/models/project_services/kubernetes_service_spec.rb
@@ -0,0 +1,126 @@
+require 'spec_helper'
+
+describe KubernetesService, models: true do
+  let(:project) { create(:empty_project) }
+
+  describe "Associations" do
+    it { is_expected.to belong_to :project }
+  end
+
+  describe 'Validations' do
+    context 'when service is active' do
+      before { subject.active = true }
+      it { is_expected.to validate_presence_of(:namespace) }
+      it { is_expected.to validate_presence_of(:api_url) }
+      it { is_expected.to validate_presence_of(:token) }
+
+      context 'namespace format' do
+        before do
+          subject.project = project
+          subject.api_url = "http://example.com"
+          subject.token = "test"
+        end
+
+        {
+          'foo'  => true,
+          '1foo' => true,
+          'foo1' => true,
+          'foo-bar' => true,
+          '-foo' => false,
+          'foo-' => false,
+          'a' * 63 => true,
+          'a' * 64 => false,
+          'a.b' => false,
+          'a*b' => false,
+        }.each do |namespace, validity|
+          it "should validate #{namespace} as #{validity ? 'valid' : 'invalid'}" do
+            subject.namespace = namespace
+
+            expect(subject.valid?).to eq(validity)
+          end
+        end
+      end
+    end
+
+    context 'when service is inactive' do
+      before { subject.active = false }
+      it { is_expected.not_to validate_presence_of(:namespace) }
+      it { is_expected.not_to validate_presence_of(:api_url) }
+      it { is_expected.not_to validate_presence_of(:token) }
+    end
+  end
+
+  describe '#initialize_properties' do
+    context 'with a project' do
+      it 'defaults to the project name' do
+        expect(described_class.new(project: project).namespace).to eq(project.name)
+      end
+    end
+
+    context 'without a project' do
+      it 'leaves the namespace unset' do
+        expect(described_class.new.namespace).to be_nil
+      end
+    end
+  end
+
+  describe '#test' do
+    let(:project) { create(:kubernetes_project) }
+    let(:service) { project.kubernetes_service }
+    let(:discovery_url) { service.api_url + '/api/v1' }
+
+    # JSON response body from Kubernetes GET /api/v1 request
+    let(:discovery_response) { { "kind" => "APIResourceList", "groupVersion" => "v1", "resources" => [] }.to_json }
+
+    context 'with path prefix in api_url' do
+      let(:discovery_url) { 'https://kubernetes.example.com/prefix/api/v1' }
+
+      before do
+        service.api_url = 'https://kubernetes.example.com/prefix/'
+      end
+
+      it 'tests with the prefix' do
+        WebMock.stub_request(:get, discovery_url).to_return(body: discovery_response)
+
+        expect(service.test[:success]).to be_truthy
+        expect(WebMock).to have_requested(:get, discovery_url).once
+      end
+    end
+
+    context 'with custom CA certificate' do
+      let(:certificate) { "CA PEM DATA" }
+      before do
+        service.update_attributes!(ca_pem: certificate)
+      end
+
+      it 'is added to the certificate store' do
+        cert = double("certificate")
+
+        expect(OpenSSL::X509::Certificate).to receive(:new).with(certificate).and_return(cert)
+        expect_any_instance_of(OpenSSL::X509::Store).to receive(:add_cert).with(cert)
+        WebMock.stub_request(:get, discovery_url).to_return(body: discovery_response)
+
+        expect(service.test[:success]).to be_truthy
+        expect(WebMock).to have_requested(:get, discovery_url).once
+      end
+    end
+
+    context 'success' do
+      it 'reads the discovery endpoint' do
+        WebMock.stub_request(:get, discovery_url).to_return(body: discovery_response)
+
+        expect(service.test[:success]).to be_truthy
+        expect(WebMock).to have_requested(:get, discovery_url).once
+      end
+    end
+
+    context 'failure' do
+      it 'fails to read the discovery endpoint' do
+        WebMock.stub_request(:get, discovery_url).to_return(status: 404)
+
+        expect(service.test[:success]).to be_falsy
+        expect(WebMock).to have_requested(:get, discovery_url).once
+      end
+    end
+  end
+end