Separate your concerns with a single core Umbraco install with many sites

TL;DR: This is about how to organize a multisite Umbraco install.

This post may take a bit to explain things and I want to pre-qualify readers so that I don't waste their time. Therefore I will begin with a list of statements on who this blog post isn't for:

  • If you're using Umbraco Cloud. This post gets very 'manual' mode pretty quickly and I'm not very confident Umbraco Cloud supports these sorts of things.
  • If you build one site per Umbraco core and never plan to do a multisite install.
  • If you build multisite solutions but don't care if you only divide clients by folders nor make any distinction between client site code and Umbraco code.

If you fall into one of the above categories, then perhaps a list of 100 Random Fun Facts is for you.

So who is this blog for?

If you write multisite installs and would like a clear separation between models, views, controllers and assets; this blog is for you.

If you have made it this far and no longer feel this post is for you, here is a link to a large list of cat images.

Ok then. If you are still reading this, we'll get on with the show. 

The problem

It turns out that web dev\hosting has a few heuristics about it that create friction, such as the following:

  • Cost - Dropping a single site into its own hosting plan can be wasteful on low-medium sites. A multisite solution can be appealing, but it is not without its drawbacks. Multisite hosting in one core also means mess up one site, you could take down the other sites.
  • Upgrading - Upgrading the Umbraco core of one site is easy (insert comment here about breaking changes). Two is beginning to feel like work (especially when a new feature is released). Beyond that you just pray that a security alert isn't issued that affects all of your sites. A multisite install keeps that much more manageable.
  • Portability - A low traffic marketing site does not require the horsepower of a full-on ticket purchasing site. However the moment the client has 'grand plans' or suddenly becomes an overnight sensation; a multisite install starts to feel like a lot of surgery to remove the conjoined websites. Or what if you want to fire the client (or they want to move on)? It'll be way easier to just give them their code if we can somehow isolate all of it.
  • Customization - Ever met a client who says, "We just want a simple site except for these 2000 changes"? In a multisite solution, it's super critical to keep this client isolated; however traditionally that's been a tough riddle to solve.

So to summarize, the problem with multisite hosting is keeping all the clients nicely separated in code so that things stay tidy.

The solution (proposed)

Let's say you've made the leap of faith to do a multisite install like myself (I've hosted many). My current use-case is my local church. They were having provider issues and I finally stepped in to say that I would host it (at my own expense time + hosting). Naturally I didn't want to drop a bunch of money on a site that won't bring traffic like the next Red Bull event. The easy win for me is to co-locate my blog with the church site. Umbraco provides all of the means to keep the users from the church site out of my blog, so the focus of this blog isn't "how to setup a multisite" solution in Umbraco. That's easy, you can find resources to do so. The riddle I want to solve for you today is how to maintain each site in a multisite environment cleanly (or at least as clean as I can get it so far).

Let's work a bit backwards

So in order to show what I think is a clean solution, let's look at the solution; then talk about how I got there. The following image shows the final result. Each client gets their own project complete with their own models, views, controllers and frontend assets. No mixing of the two. Traditionally most multisite Umbraco installs have to co-locate views in a single folder. What's great about this solution is the Umbraco core is nearly pristine. The only thing that sullies it up a bit is the App Plugins folder and any other third-party package artifacts. For now that is not my battle. But perhaps one day that can be factored out as well.

The path to salvation

So next I want to share a brief list of obstacles I had to overcome. Splitting C# into separate projects is no secret, however splitting the assets and views away from the Umbraco project is a little tougher and there is a twist. Look at the image below:

Part one of this is as-follows:

  1. Use a post-build action to copy to the Umbraco project
  2. Make sure to not include these items in the Umbraco project (which is why they are greyed out)
  3. Set Git to ignore these files

This will make it all work as you would normally expect when you run Umbraco locally. I setup the 'client' projects as ASP.NET projects so include MVC ref's so that the razor views had proper intellisense.

The post-build commands are the same on all client projects (should you want to make a template):

xcopy /E /Y /i "$(ProjectDir)Views" "$(SolutionDir)KGLLC.Umbraco\Views"
xcopy /E /Y /i "$(ProjectDir)assets" "$(SolutionDir)KGLLC.Umbraco\assets"

These post-build events are standard XCOPY with some switches to avoid being held up by a user prompt. You can easily google 'post-build events' in Visual Studio if you have more questions (or where to put them in each project). Adjust your commands to the names of your projects.

The next part then is the .gitignore stuff which looks like this:

src/KGLLC.Umbraco/Views/**/*.cshtml
src/KGLLC.Umbraco/assets/*

Keep in mind the goal of the ignores above is to keep the /Views and /assets folder clean (Side note, this is a great command for Git to 'reapply' the .gitignore https://stackoverflow.com/a/7532131/1141002).

Ok, so if you're familiar with post-build events you're not quite impressed yet. Next up my sleeve is telling Umbraco to search the view subfolders instead of just the root /Views folder. That is accomplished by this bit of code here:

using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using System.IO;
using Umbraco.Core.Logging;
using Umbraco.Core.IO;

namespace MyNamespace
{
    public class MyViewEngine : RazorViewEngine
    {
        public MyViewEngine ()
        {
            var viewsPath = IOHelper.MapPath("~/Views");

            var directories = Directory.GetDirectories(viewsPath);

            var pathList = new List<string>();

            foreach (var directory in directories.Where(x => !x.ToLower().Contains("partials")))
            {
                var folder = Path.GetFileName(directory);

                var path = string.Format("~/Views/{0}/{{0}}.cshtml", folder);

                pathList.Add(path);

                LogHelper.Info<MyViewEngine >("Registering view engine path: " + folder);
            }

            ViewLocationFormats = ViewLocationFormats.Union(pathList).ToArray();
        }
    }
}

This is just really expanding the search 'radius' of the ViewEngine. To wire it up to Umbraco, refer to this post here.

So a landmine awaits you if you stop there. It's because by default, Umbraco will create all new templates in the root /Views folder. This is problematic in our situation because as the runtime searches for the template to be used, it will stop as soon as it finds one. And if one lives in the root with the right name, it'll pick it and completely ignore the nice one you've been working on all day. So we need more code:

using System.IO;
using System.Net;
using Umbraco.Core;
using Umbraco.Core.IO;
using Umbraco.Core.Logging;

namespace KGLLC.Umbraco.Common.Events
{
    public class StartupKgLlc : ApplicationEventHandler
    {
        protected override void ApplicationStarting(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
        {
            base.ApplicationStarting(umbracoApplication, applicationContext);

            _removeViewsFromRoot();
        }

        private void _removeViewsFromRoot()
        {
            var viewPath = IOHelper.MapPath("~/Views");

            foreach (var file in Directory.GetFiles(viewPath, $"*.cshtml"))
            {
                LogHelper.Info<StartupKgLlc>($"Removing view artifact: {file}");

                File.Delete(file);
            }
        }
    }
}

This code automatically will clean up these errant template files on startup. This is important and if you didn't do this, the symptom of this would be a blank screen on your freshly deployed instance.

Which takes me to my next issue: Azure.

Azure

With post-build events, it all works beautifully on my machine. However as soon as I published to Azure (via Git push), my assets and templates no longer copied to the correct locations. It turns out that post-build events do work on Azure, but not like you might think. What happens is the post-build gets executed in the folder named 'repository' and not the 'wwwroot'.

Kudu, which is the Azure tool to do many things has a normal process and it will not 'see' this post-build event output. What is then needed is a custom Kudu build step. It sounds tough, but all you have to do is download the template (which includes all of the 'normal' steps); then add two lines of code. The following is the final build file: https://gist.github.com/kgiszewski/f8854914d87bb4c87b747697b098acf8 and the two lines of code (per site) are at the bottom which look like this:

echo Deploying view files...
xcopy /E /Y /i %DEPLOYMENT_SOURCE%\src\KGLLC.Blog\Views %DEPLOYMENT_TARGET%\Views
xcopy /E /Y /i %DEPLOYMENT_SOURCE%\src\KGLLC.OakGrove\Views %DEPLOYMENT_TARGET%\Views
echo Deploying asset files...
xcopy /E /Y /i %DEPLOYMENT_SOURCE%\src\KGLLC.Blog\assets %DEPLOYMENT_TARGET%\assets
xcopy /E /Y /i %DEPLOYMENT_SOURCE%\src\KGLLC.OakGrove\assets %DEPLOYMENT_TARGET%\assets

There is a Kudu tutorial here if you'd like to learn more: https://github.com/projectkudu/kudu/wiki/Custom-Deployment-Script

To execute your custom build process, commit your deployment script to your repo (at the root) along with a special file called `.deployment`. It has very little code in it:

[config]
command = deploy.cmd

When you commit and push these files with Git to Azure, the custom build script will execute:

 

So I know what you're thinking...

You're probably thinking this is a bunch of work and I can't disagree but hopefully I've blazed the trail enough to make it pretty easy for anyone following behind me. Since I find multisite instances very cost effective and manageable (despite some risks), I've always wanted to keep the code separated and couldn't really find a solid way until now. While not in the scope of this blog, just for kicks I wanted to share what my backoffice looks like in a multisite setup:

So despite the trouble to set it all up, I find things are very clear and easy to work with. I hope this conjures some thoughts for your own situation. 

Edits after this first published

Tim Payne asked a good question about templates. Templates still need to be strongly named across all client sites and is still driven by the Umbraco DB.

NodeHelper

A few years back when I did my first multisite design, I wrote something called NodeHelper. It is a Singleton that 'knows' how to determine very quickly which site the user is on:

<div class="col-md-8">
    @foreach (var blog in NodeHelper.Instance.CurrentSite.Home.Children.First(x => x.DocumentTypeAlias == "BlogsFolder").Descendants("BlogPostPage").OrderByDescending(x => x.ToPublishedDate()).Take(3))
    {
        @(Html.Partial("~/Views/Blog/Partials/BlogTease.cshtml", blog))
    }
</div>	

<script>
 @Html.Raw(NodeHelper.Instance.CurrentSite.SiteSettings.GetPropertyValue<string>("googleAnalytics"))
</script>

Clusters

You can also have multiple multitenant sites which I call clusters. Each cluster has a single core, many clients, etc. At the very least it reduces how many cores you have to deal with.

Grunt

I've evolved the post-build events a bit by using Grunt instead.