diff --git a/README.md b/README.md
index edcb9118f21e69a6e972ce6ce36fc97a86003897..39790d27a83e70f900ef1c42e9547ce2afec9b6b 100644
--- a/README.md
+++ b/README.md
@@ -93,3 +93,22 @@ to also deploy the dashboard with load balancing proxy such as HAProxy:
 
 This option potentially provides better scale-out than using the charm in
 conjunction with the hacluster charm.
+
+
+Custom Theme
+============
+This charm supports providing a custom theme as documented in the [themes
+configuration]. In order to enable this capability the configuration options
+'ubuntu-theme' and 'default-theme' must both be turned off and the option
+'custom-theme' turned on.
+
+Once the option is enabled a custom theme can be provided via a juju resource.
+The resource should be a .tgz file with the contents of your custom theme. If
+the file 'local_settings.py' is included it will be sourced.
+
+    juju attach-resource openstack-dashboard theme=theme.tgz 
+
+Repeating the attach-resource will update the theme and turning off the
+custom-theme option will return to the default.
+
+[themes]: https://docs.openstack.org/horizon/latest/configuration/themes.html
diff --git a/config.yaml b/config.yaml
index e9d747eff8d12c9af8bb88078b8ac9c512f20dab..af9afc41446bb6bca867631e9dacd52b140a894b 100644
--- a/config.yaml
+++ b/config.yaml
@@ -175,6 +175,13 @@ options:
       .
       NOTE: This setting is supported >= OpenStack Liberty and
       this setting is mutually exclusive to ubuntu-theme.
+  custom-theme:
+    type: boolean
+    default: False
+    description: |
+      Use a custom theme supplied as a resource.
+      NOTE: This setting is supported >= OpenStack Mitaka and
+      this setting is mutually exclustive to ubuntu-theme and default-theme.
   secret:
     type: string
     default:
diff --git a/hooks/horizon_contexts.py b/hooks/horizon_contexts.py
index 6b74a835e7cf4cfdb140eaee00dc6becbb66e9d9..5e62afacf08b205b08f7f28886afe67934b853cc 100644
--- a/hooks/horizon_contexts.py
+++ b/hooks/horizon_contexts.py
@@ -182,6 +182,7 @@ class HorizonContext(OSContextGenerator):
             "webroot": config('webroot') or '/',
             "ubuntu_theme": bool_from_string(config('ubuntu-theme')),
             "default_theme": config('default-theme'),
+            "custom_theme": config('custom-theme'),
             "secret": config('secret') or pwgen(),
             'support_profile': config('profile')
             if config('profile') in ['cisco'] else None,
@@ -210,7 +211,8 @@ class ApacheContext(OSContextGenerator):
             'http_port': 70,
             'https_port': 433,
             'enforce_ssl': False,
-            'hsts_max_age_seconds': config('hsts-max-age-seconds')
+            'hsts_max_age_seconds': config('hsts-max-age-seconds'),
+            "custom_theme": config('custom-theme'),
         }
 
         if config('enforce-ssl'):
diff --git a/hooks/horizon_hooks.py b/hooks/horizon_hooks.py
index ba9978d7fdd87113169945488f23607bc124223e..254a38affe5f1d26c72e356a97ffc53743491e5a 100755
--- a/hooks/horizon_hooks.py
+++ b/hooks/horizon_hooks.py
@@ -64,6 +64,7 @@ from horizon_utils import (
     restart_on_change,
     assess_status,
     db_migration,
+    check_custom_theme,
 )
 from charmhelpers.contrib.network.ip import (
     get_iface_for_address,
@@ -110,6 +111,7 @@ def upgrade_charm():
     apt_install(filter_installed_packages(determine_packages()), fatal=True)
     update_nrpe_config()
     CONFIGS.write_all()
+    check_custom_theme()
 
 
 @hooks.hook('config-changed')
@@ -150,6 +152,7 @@ def config_changed():
     save_script_rc(**env_vars)
     update_nrpe_config()
     CONFIGS.write_all()
+    check_custom_theme()
     open_port(80)
     open_port(443)
 
diff --git a/hooks/horizon_utils.py b/hooks/horizon_utils.py
index 6be0db385d91d2a0b029b33ab1be664617bd035c..6d15e8ac1a3f053ab3ddd313f0af6679dd0ff592 100644
--- a/hooks/horizon_utils.py
+++ b/hooks/horizon_utils.py
@@ -17,6 +17,7 @@ import horizon_contexts
 import os
 import subprocess
 import time
+import tarfile
 from collections import OrderedDict
 
 import charmhelpers.contrib.openstack.context as context
@@ -36,7 +37,8 @@ from charmhelpers.contrib.openstack.utils import (
 )
 from charmhelpers.core.hookenv import (
     config,
-    log
+    log,
+    resource_get,
 )
 from charmhelpers.core.host import (
     cmp_pkgrevno,
@@ -86,6 +88,9 @@ ROUTER_SETTING = ('/usr/share/openstack-dashboard/openstack_dashboard/enabled/'
 KEYSTONEV3_POLICY = ('/usr/share/openstack-dashboard/openstack_dashboard/conf/'
                      'keystonev3_policy.json')
 TEMPLATES = 'templates'
+CUSTOM_THEME_DIR = ("/usr/share/openstack-dashboard/openstack_dashboard/"
+                    "themes/custom")
+LOCAL_DIR = '/usr/share/openstack-dashboard/openstack_dashboard/local/'
 
 CONFIG_FILES = OrderedDict([
     (LOCAL_SETTINGS, {
@@ -414,3 +419,27 @@ def db_migration():
         subcommand = 'syncdb'
     cmd = ['/usr/share/openstack-dashboard/manage.py', subcommand, '--noinput']
     subprocess.check_call(cmd)
+
+
+def check_custom_theme():
+    if not config('custom-theme'):
+        log('No custom theme configured, exiting')
+        return
+    try:
+        os.mkdir(CUSTOM_THEME_DIR)
+    except OSError as e:
+        if e.errno is 17:
+            pass  # already exists
+    theme_file = resource_get('theme')
+    log('Retreived resource: {}'.format(theme_file))
+    if theme_file:
+        with tarfile.open(theme_file, 'r:gz') as in_file:
+            in_file.extractall(CUSTOM_THEME_DIR)
+    custom_settings = '{}/local_settings.py'.format(CUSTOM_THEME_DIR)
+    if os.path.isfile(custom_settings):
+        try:
+            os.symlink(custom_settings, LOCAL_DIR + 'custom_theme.py')
+        except OSError as e:
+            if e.errno is 17:
+                pass  # already exists
+    log('Custom theme updated'.format(theme_file))
diff --git a/metadata.yaml b/metadata.yaml
index 6a8eda92a69aa919abadd1e2ad36f6eaa87ce96f..98c1a0a8cedb48ed0333289db7bcd0080827bf7b 100644
--- a/metadata.yaml
+++ b/metadata.yaml
@@ -38,3 +38,8 @@ requires:
 peers:
   cluster:
     interface: openstack-dashboard-ha
+resources:
+  theme:
+    type: file
+    filename: theme.tgz
+    description: "Custom dashboard theme"
diff --git a/templates/mitaka/local_settings.py b/templates/mitaka/local_settings.py
index ec9305789059c9148ab6f2c98e89737b87f6bb69..51523d1ed73f16659c923e72070012f38f37f4b6 100644
--- a/templates/mitaka/local_settings.py
+++ b/templates/mitaka/local_settings.py
@@ -861,6 +861,13 @@ if '{{ default_theme }}' not in [el[0] for el in AVAILABLE_THEMES]:
        'themes/{{ default_theme }}'),
   ]
 DEFAULT_THEME = '{{ default_theme }}'
+{% elif custom_theme %}
+AVAILABLE_THEMES = []
+try:
+  from custom_theme import *
+except ImportError:
+  pass
+AVAILABLE_THEMES += [ ('custom', 'custom', 'themes/custom') ]
 {% endif %}
 
 WEBROOT = '{{ webroot }}'
diff --git a/templates/mitaka/openstack-dashboard.conf b/templates/mitaka/openstack-dashboard.conf
new file mode 100644
index 0000000000000000000000000000000000000000..97dbf23da0e8735c73f1654d0f155a256c212699
--- /dev/null
+++ b/templates/mitaka/openstack-dashboard.conf
@@ -0,0 +1,14 @@
+WSGIScriptAlias {{ webroot }} /usr/share/openstack-dashboard/openstack_dashboard/wsgi/django.wsgi
+WSGIDaemonProcess horizon user=horizon group=horizon processes={{ processes }} threads=10
+WSGIProcessGroup horizon
+{% if custom_theme %}
+Alias /static/custom /usr/share/openstack-dashboard/openstack_dashboard/themes/custom/static/
+Alias /static/themes/custom /usr/share/openstack-dashboard/openstack_dashboard/themes/custom/static/
+{% endif %}
+Alias /static /usr/share/openstack-dashboard/openstack_dashboard/static/
+Alias /horizon/static /usr/share/openstack-dashboard/openstack_dashboard/static/
+
+<Directory /usr/share/openstack-dashboard/openstack_dashboard/wsgi>
+  Order allow,deny
+  Allow from all
+</Directory>
diff --git a/templates/newton/local_settings.py b/templates/newton/local_settings.py
index aea9c422dcba91a3ae392e1562e2c878e666261d..2b6235bf12d991763f38b0f72f8fb6a4f2b38fef 100644
--- a/templates/newton/local_settings.py
+++ b/templates/newton/local_settings.py
@@ -900,6 +900,13 @@ if '{{ default_theme }}' not in [el[0] for el in AVAILABLE_THEMES]:
        'themes/{{ default_theme }}'),
   ]
 DEFAULT_THEME = '{{ default_theme }}'
+{% elif custom_theme %}
+AVAILABLE_THEMES = []
+try:
+  from custom_theme import *
+except ImportError:
+  pass
+AVAILABLE_THEMES += [ ('custom', 'custom', 'themes/custom') ]
 {% endif %}
 
 WEBROOT = '{{ webroot }}'
diff --git a/templates/newton/openstack-dashboard.conf b/templates/newton/openstack-dashboard.conf
index 28a1dc0322555c914739edfeab4d06f21ed477d7..d07485065afa38cb8803024d878444cd0542cd4e 100644
--- a/templates/newton/openstack-dashboard.conf
+++ b/templates/newton/openstack-dashboard.conf
@@ -1,6 +1,9 @@
 WSGIScriptAlias {{ webroot }} /usr/share/openstack-dashboard/openstack_dashboard/wsgi/django.wsgi
 WSGIDaemonProcess horizon user=horizon group=horizon processes={{ processes }} threads=10
 WSGIProcessGroup horizon
+{% if custom_theme %}
+Alias /static/themes/custom /usr/share/openstack-dashboard/openstack_dashboard/themes/custom/static/
+{% endif %}
 Alias /static /usr/share/openstack-dashboard/openstack_dashboard/static/
 Alias /horizon/static /usr/share/openstack-dashboard/openstack_dashboard/static/
 <Directory /usr/share/openstack-dashboard/openstack_dashboard/wsgi>
diff --git a/templates/ocata/local_settings.py b/templates/ocata/local_settings.py
index 3d43c562a773068afac56130599ef8d216976b13..7f452cb32db8688d955e89ed44c6cdac67f1398a 100644
--- a/templates/ocata/local_settings.py
+++ b/templates/ocata/local_settings.py
@@ -902,6 +902,13 @@ if '{{ default_theme }}' not in [el[0] for el in AVAILABLE_THEMES]:
        'themes/{{ default_theme }}'),
   ]
 DEFAULT_THEME = '{{ default_theme }}'
+{% elif custom_theme %}
+AVAILABLE_THEMES = []
+try:
+  from custom_theme import *
+except ImportError:
+  pass
+AVAILABLE_THEMES += [ ('custom', 'custom', 'themes/custom') ]
 {% endif %}
 
 WEBROOT = '{{ webroot }}'
diff --git a/templates/ocata/openstack-dashboard.conf b/templates/ocata/openstack-dashboard.conf
index 99feddfb4ef5e6ce345b86a65b44f8fa970ba8e7..4b0c3e23149492499ea553769a05b0be65a9cc6a 100644
--- a/templates/ocata/openstack-dashboard.conf
+++ b/templates/ocata/openstack-dashboard.conf
@@ -1,6 +1,9 @@
 WSGIScriptAlias {{ webroot }} /usr/share/openstack-dashboard/openstack_dashboard/wsgi/django.wsgi
 WSGIDaemonProcess horizon user=horizon group=horizon processes={{ processes }} threads=10
 WSGIProcessGroup horizon
+{% if custom_theme %}
+Alias /static/themes/custom /usr/share/openstack-dashboard/openstack_dashboard/themes/custom/static/
+{% endif %}
 Alias /static /var/lib/openstack-dashboard/static/
 Alias /horizon/static /var/lib/openstack-dashboard/static/
 <Directory /usr/share/openstack-dashboard/openstack_dashboard/wsgi>
diff --git a/templates/pike/openstack-dashboard.conf b/templates/pike/openstack-dashboard.conf
index 99feddfb4ef5e6ce345b86a65b44f8fa970ba8e7..4b0c3e23149492499ea553769a05b0be65a9cc6a 100644
--- a/templates/pike/openstack-dashboard.conf
+++ b/templates/pike/openstack-dashboard.conf
@@ -1,6 +1,9 @@
 WSGIScriptAlias {{ webroot }} /usr/share/openstack-dashboard/openstack_dashboard/wsgi/django.wsgi
 WSGIDaemonProcess horizon user=horizon group=horizon processes={{ processes }} threads=10
 WSGIProcessGroup horizon
+{% if custom_theme %}
+Alias /static/themes/custom /usr/share/openstack-dashboard/openstack_dashboard/themes/custom/static/
+{% endif %}
 Alias /static /var/lib/openstack-dashboard/static/
 Alias /horizon/static /var/lib/openstack-dashboard/static/
 <Directory /usr/share/openstack-dashboard/openstack_dashboard/wsgi>
diff --git a/tests/basic_deployment.py b/tests/basic_deployment.py
index 78c8f0e5c298767233907bf87c752ef5eb27cedc..9186adc6e70e5c34b9038ccbb70edbbf7d066111 100644
--- a/tests/basic_deployment.py
+++ b/tests/basic_deployment.py
@@ -230,7 +230,7 @@ class OpenstackDashboardBasicDeployment(OpenStackAmuletDeployment):
         #                add retry logic to unwedge the gate.  This issue
         #                should be revisited and root caused properly when time
         #                allows.
-        @retry_on_exception(1)
+        @retry_on_exception(2, base_delay=2)
         def do_request():
             response = urllib2.urlopen('http://%s/horizon' % (dashboard_ip))
             return response.read()
diff --git a/unit_tests/test_horizon_contexts.py b/unit_tests/test_horizon_contexts.py
index 8f5cea728ab90f4f99173b7a963e3e58608c7f8b..765b6958cd1fa245b7fdf84d9033a88c0efd8c2d 100644
--- a/unit_tests/test_horizon_contexts.py
+++ b/unit_tests/test_horizon_contexts.py
@@ -64,7 +64,8 @@ class TestHorizonContexts(CharmTestCase):
         self.assertEqual(horizon_contexts.ApacheContext()(),
                          {'http_port': 70, 'https_port': 433,
                           'enforce_ssl': False,
-                          'hsts_max_age_seconds': 0})
+                          'hsts_max_age_seconds': 0,
+                          'custom_theme': False})
 
     def test_Apachecontext_enforce_ssl(self):
         self.test_config.set('enforce-ssl', True)
@@ -72,7 +73,8 @@ class TestHorizonContexts(CharmTestCase):
         self.assertEquals(horizon_contexts.ApacheContext()(),
                           {'http_port': 70, 'https_port': 433,
                            'enforce_ssl': True,
-                           'hsts_max_age_seconds': 0})
+                           'hsts_max_age_seconds': 0,
+                           'custom_theme': False})
 
     def test_Apachecontext_enforce_ssl_no_cert(self):
         self.test_config.set('enforce-ssl', True)
@@ -80,7 +82,8 @@ class TestHorizonContexts(CharmTestCase):
         self.assertEquals(horizon_contexts.ApacheContext()(),
                           {'http_port': 70, 'https_port': 433,
                            'enforce_ssl': False,
-                           'hsts_max_age_seconds': 0})
+                           'hsts_max_age_seconds': 0,
+                           'custom_theme': False})
 
     def test_Apachecontext_hsts_max_age_seconds(self):
         self.test_config.set('enforce-ssl', True)
@@ -89,7 +92,8 @@ class TestHorizonContexts(CharmTestCase):
         self.assertEquals(horizon_contexts.ApacheContext()(),
                           {'http_port': 70, 'https_port': 433,
                            'enforce_ssl': True,
-                           'hsts_max_age_seconds': 15768000})
+                           'hsts_max_age_seconds': 15768000,
+                           'custom_theme': False})
 
     @patch.object(horizon_contexts, 'get_ca_cert', lambda: None)
     @patch('os.chmod')
@@ -125,6 +129,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -150,6 +155,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -175,6 +181,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -200,6 +207,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': False,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -226,6 +234,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': False,
                           'default_theme': 'material',
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -255,6 +264,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -280,6 +290,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'foo', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -305,6 +316,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -335,6 +347,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": True,
@@ -360,6 +373,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -385,6 +399,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -411,6 +426,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -437,6 +453,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
@@ -463,6 +480,7 @@ class TestHorizonContexts(CharmTestCase):
                           'default_role': 'Member', 'webroot': '/horizon',
                           'ubuntu_theme': True,
                           'default_theme': None,
+                          'custom_theme': False,
                           'secret': 'secret',
                           'support_profile': None,
                           "neutron_network_dvr": False,
diff --git a/unit_tests/test_horizon_hooks.py b/unit_tests/test_horizon_hooks.py
index 4d0d3f0ef0f9be3c422045c72e3348e684f2e196..e7c20f2428e63589a9c08ee71665ce192a379d9e 100644
--- a/unit_tests/test_horizon_hooks.py
+++ b/unit_tests/test_horizon_hooks.py
@@ -132,11 +132,13 @@ class TestHorizonHooks(CharmTestCase):
             )
         self.assertTrue(self.apt_install.called)
 
+    @patch('horizon_hooks.check_custom_theme')
     @patch.object(hooks, 'determine_packages')
     @patch.object(utils, 'path_hash')
     @patch.object(utils, 'service')
     def test_upgrade_charm_hook(self, _service, _hash,
-                                _determine_packages):
+                                _determine_packages,
+                                _custom_theme):
         _determine_packages.return_value = []
         side_effects = []
         [side_effects.append(None) for f in RESTART_MAP.keys()]
@@ -155,6 +157,7 @@ class TestHorizonHooks(CharmTestCase):
             call('start', 'haproxy'),
         ]
         self.assertEqual(ex, _service.call_args_list)
+        self.assertTrue(_custom_theme.called)
 
     def test_ha_joined_complete_config(self):
         conf = {
@@ -258,8 +261,9 @@ class TestHorizonHooks(CharmTestCase):
         self.assertTrue(self.update_dns_ha_resource_params.called)
         self.relation_set.assert_called_with(**args)
 
+    @patch('horizon_hooks.check_custom_theme')
     @patch('horizon_hooks.keystone_joined')
-    def test_config_changed_no_upgrade(self, _joined):
+    def test_config_changed_no_upgrade(self, _joined, _custom_theme):
         def relation_ids_side_effect(rname):
             return {
                 'websso-trusted-dashboard': [
@@ -295,13 +299,16 @@ class TestHorizonHooks(CharmTestCase):
         self.assertTrue(self.save_script_rc.called)
         self.assertTrue(self.CONFIGS.write_all.called)
         self.open_port.assert_has_calls([call(80), call(443)])
+        self.assertTrue(_custom_theme.called)
 
-    def test_config_changed_do_upgrade(self):
+    @patch('horizon_hooks.check_custom_theme')
+    def test_config_changed_do_upgrade(self, _custom_theme):
         self.relation_ids.return_value = []
         self.test_config.set('openstack-origin', 'cloud:precise-grizzly')
         self.openstack_upgrade_available.return_value = True
         self._call_hook('config-changed')
         self.assertTrue(self.do_openstack_upgrade.called)
+        self.assertTrue(_custom_theme.called)
 
     def test_keystone_joined_in_relation(self):
         self._call_hook('identity-service-relation-joined')