Issues

Integrating Cloudflare CDN with Umbraco Websites - Part 2: Cloudflare's Key Features and Full-Page Caching

In today's fast-paced digital landscape, ensuring optimal website performance and reliability is paramount for online businesses seeking to deliver exceptional user experiences. Content Delivery Networks (CDNs) have emerged as indispensable tools in achieving these goals by providing efficient content delivery, enhanced scalability, and robust security features.

In part 1, I discussed what a CDN was and how it works, and setting up the Cloudflare CDN. In this article, I'll show you the key features of Cloudflare and you'll see how to use the Cloudflare CDN for full-page caching of your Umbraco website while using the Cloudflare API to purge the CDN cache for the Umbraco pages that have been published, unpublished, or deleted.

Cloudflare’s key features

Cloudflare has many features. Depending on your needs, you might need to spend more time in some other sections, but most of the time, you’ll be working with the following key features:

  • SSL/TLS
  • Security
  • Firewall
  • Speed
  • Caching
  • Rules

SSL/TLS

This is where you have multiple options to encrypt your web traffic to prevent data theft and other tampering.

You can choose one of the encryption modes, and Full is the recommended option for most websites. You also use this section to manage your certificates as well as your host names.

Security and Firewall

One of the most important sections is the Security section. This is the place to review mitigated requests and tailor your security configurations. 

You can create Firewall events and WAF-managed rulesets to protect your site. You can use the Page Shield feature to keep track of your application’s JavaScript dependencies and receive alerts when they change or in the Bots section, you can identify and mitigate automated traffic to protect your domain from bad bots. Using the DDoS feature, you can customize the default HTTP DDoS Attack Protection managed ruleset by modifying the following parameters:

  • The performed action when an attack is detected.
  • The sensitivity level of attack detection mechanisms. 

Depending on your Cloudflare plan, you can access additional graphs and features, like Bot Fight Mode, IP Access Rules, and Security Level modes like “I’m Under Attack!” mode. 

Speed

This is the section where you can run tests with synthetic data and real user data from browsers to assess the performance of your website. These data sources produce metrics that provide different types of insights into your website’s performance. Cloudflare then uses the analysis run by Observatory to recommend optimisations with the tools that best suit your performance issues. 

 Depending on your Cloudflare plan, you can Auto Minify JavaScript, CSS and HTML to reduce the file size of source code on your website. You can also turn Rocket Loader on to improve the performance of pages that include JavaScript.

Caching

This is the section to determine how your site is cached.

Cloudflare CDN caches the static assets of your website and it stores them, and it tries to deliver them from a data centre that is closer to the user, rather than having them connect back to your origin server. The caching level is very important and you can leave it as Standard. 

The Browser Cache TTL should be determined based on the amount of changes you implement. It can be hours or even months if things don’t change often. 

This section is also the place where you can come and purge your Cloudflare cache anytime. Purge Everything will clear out all the Cloudflare cache, alternatively, you can purge the cache for individual pages.

Some other settings are Always Online and Development Mode. 

Always Online will try and keep your website up if the origin server is unavailable, and if your origin server goes down for whatever reason, Cloudflare will try and keep your page online as long as it can by using the cached versions. Having said that this is just a temporary solution and you should fix the actual problem(s) on your origin server permanently to continue to serve your web users. 

Development Mode will temporarily bypass the cache and it will allow you to see the changes in real time. If you are making some major changes to your site, especially if you are developing some new features on your development server, you might want to set this to On so that you can see the changes in real-time. When you are done, make sure to flip this setting back to Off.

Rules

Configuration rules allow you to customise certain Cloudflare settings for matching incoming requests and trigger specific actions for matching requests. The configuration rule expression will determine to which requests the rule settings will apply.

The free tier of Cloudflare comes with ten free page rules that you can implement. A rule can be something like forwarding all your requests from one URL to another. It can be for caching your entire page, not just front-end assets but the HTML pages, too. This takes the default cache and builds on top of that and takes it one step further. 

With the correct setup, you can cache every single individual page which can greatly improve the overall website performance while reducing the origin server resources as most requests will be handled by Cloudflare cache.

These are the most important features that you should use to improve the performance and security of your websites.

You can always put Cloudflare in front of your website but that’s not to say that you can’t use Cloudflare for your home services as well. Cloudflare offers a range of features for free, and they have paid plans. You can always upgrade to a paid plan later if the free plan is not enough for you.

If you are using a different CDN, that is probably fine. However, if you’re not using any CDN, then Cloudflare is a great option to improve your website’s performance and security.

Cloudflare page rules and full-page caching

Cloudflare Page Rules let you control which Cloudflare settings trigger on a given URL. Only one Page Rule will trigger per URL, so it is helpful if you sort Page Rules in priority order and make your URL patterns as specific as possible. 

Configuration Rules page is where you create your rules for full-page caching. After hitting the Create rule button, you need to enter a URL, i.e., https://domainname.com/page-to-cache/, and then pick the Cache Level setting and set it to Cache Everything. This will cache everything on this page, including front-end assets and HTML, too.

If you are using the Free version of Cloudflare CDN, you can create up to 10 page rules. For paid versions, you can create more rules.

Some other page rule examples could be as follows:

You can go to the Caching Overview page to check the cache performance of your website. This is where you can check whether your pages that have page rules are served from Cloudflare cache or your origin server. If you have created full-page caching rules for some of your pages, then most of the requests should be served from Cloudflare cache, rather than the origin server as displayed below.

Purging Cloudflare cache from an Umbraco website

When using the full-page caching features of Cloudflare for your website pages, it is important to serve updated content to your web users after any changes to your web pages. 

The best way to update the Cloudflare cache is to purge the Cloudflare cache by using Cloudflare API.

Purge cached content by URL

For most Umbraco websites, it is best to purge cached content by page URL as you have full control over purging the Cloudflare cache for a specific web page. This approach granularly removes cached items (HTML and front-end assets like images, CSS and JavaScript files) from Cloudflare’s cache by specifying URLs.

For Umbraco websites, you should purge the Cloudflare cache by URLs programmatically after a content page is published, unpublished or deleted.

Below is how you can achieve this, you can find the sample code on this GitHub repo.  

Settings and Startup

The first step is to add your Zone ID and Global API key to your project. You get these details from your Cloudflare Overview page.

appsettings.json:

{
  // ...removed for brevity
  "Cloudflare": {
    "PurgeEnabled": true,
    "ClientApiBaseUrl": "https://api.cloudflare.com/client/v4",
    "DomainUrl": "front-end-domain-url",
    "ZoneId": "ZONE_ID_FROM_CLOUDFLARE",
    "UserEmail": "USER_EMAIL",
    "ApiKey": "GLOBAL_API_KEY_FROM_CLOUDFLARE"
  }
  // ...removed for brevity
 }

You add the new Cloudflare HTTP client to your startup class, so that you can consume it from your Umbraco project. Similarly, you add your Cloudflare settings section as a configuration setting, so that you can reach your Cloudflare settings easier.

Startup.cs:

// ...removed for brevity
namespace SampleProjectUmbraco.Web
{
    public class Startup
    {
        /// <summary>
        /// Configures the services.
        /// </summary>
        /// <param name="services">The services.</param>
        /// <remarks>
        /// This method gets called by the runtime. Use this method to add services to the container.
        /// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        /// </remarks>
        public void ConfigureServices(IServiceCollection services)
        {
            // ...removed for brevity
            services.Configure<CloudflareSettings>(_config.GetSection("Cloudflare"));
            services.AddHttpClient("cloudflarePurgeCache", c => new CloudflarePurgeCacheClient(c));
            // ...removed for brevity
        }
        // ...removed for brevity
    }
}

Models

Below are the models that you will need to create for this implementation. 

CloudflareSettings.cs:

namespace SampleProjectUmbraco.Models.Cloudflare
{
    public class CloudflareSettings
    {
        public bool PurgeEnabled { get; set; }


        public string ClientApiBaseUrl { get; set; }


        public string ZoneId { get; set; }


        public string UserEmail { get; set; }


        public string ApiKey { get; set; }
    }
}

CloudflareFileInfo.cs:

using Newtonsoft.Json;
using System.Collections.Generic;


namespace SampleProjectUmbraco.Models.Cloudflare
{
    public class CloudflareFileInfo
    {
        [JsonProperty("files")]
        public List<string> Files { get; set; }
    }
}

CloudflarePurgeCacheResponse.cs:

namespace SampleProjectUmbraco.Models.Cloudflare
{
    public class CloudflarePurgeCacheResponse
    {
        public CloudflareResponse CloudflareResponse { get; set; }


        public string CloudflareResponseJSON { get; set; }
    }
}

CloudflareResponse.cs:

using Newtonsoft.Json;
using System.Collections.Generic;


namespace SampleProjectUmbraco.Models.Cloudflare
{
    public class CloudflareResponse
    {
        [JsonProperty("success")]
        public bool Success { get; set; }


        [JsonProperty("errors")]
        public IList<object> Errors { get; set; }


        [JsonProperty("messages")]
        public IList<object> Messages { get; set; }


        [JsonProperty("result")]
        public CloudflareResponseResult Result { get; set; }
    }
}

CloudflareResponseResult.cs:

using Newtonsoft.Json;


namespace SampleProjectUmbraco.Models.Cloudflare
{
    public class CloudflareResponseResult
    {
        [JsonProperty("id")]
        public string Id { get; set; }
    }
}

Cloudflare purge cache client

Here is your new Cloudflare purge cache client to purge the Cloudflare cache.

CloudflarePurgeCacheClient.cs:

using SampleProjectUmbraco.Common.Extensions;
using System;
using System.Net.Http;


namespace SampleProjectUmbraco.Core.Clients
{
    /// <summary>
    /// Cloudflare Http client to purge the cloudflare cache
    /// </summary>
    public class CloudflarePurgeCacheClient
    {
        public CloudflarePurgeCacheClient(HttpClient client)
        {
            var clientApiBaseUrl =   UmbracoServicesAccessor.GetSection("Cloudflare:ClientApiBaseUrl");
            var zoneId = UmbracoServicesAccessor.GetSection("Cloudflare:ZoneId");
            var userEmail = UmbracoServicesAccessor.GetSection("Cloudflare:UserEmail");
            var apiKey = UmbracoServicesAccessor.GetSection("Cloudflare:ApiKey");


            client.BaseAddress = new Uri($"{clientApiBaseUrl}/zones/{zoneId}/purge_cache");
            client.DefaultRequestHeaders.Add("X-Auth-Email", userEmail);
            client.DefaultRequestHeaders.Add("X-Auth-Key", apiKey);
        }
    }
}

Business logic

Cache handler

The following cache handler is what you will be calling from your notification handlers to purge the Cloudflare cache. 

CloudflareCacheHandler.cs:

using SampleProjectUmbraco.Models.Cloudflare;
using Newtonsoft.Json;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;


namespace SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.CacheHandlers
{
    public static class CloudflareCacheHandler
    {
        /// <summary>
        /// Purges cloudflare cache by URLs
        /// </summary>
        /// <param name="uRLs"></param>
        /// <param name="httpClientFactory"></param>
        /// <returns></returns>
        public static async Task<CloudflarePurgeCacheResponse> PurgeCloudflareCacheByURLs(List<string> uRLs, IHttpClientFactory httpClientFactory)
        {
            CloudflarePurgeCacheResponse purgeResponse = null;

            if (uRLs != null && uRLs.Count > 0)
            {
                var serializedData = JsonConvert.SerializeObject(new CloudflareFileInfo
                {
                    Files = uRLs
                });

                purgeResponse = await Purge(httpClientFactory, serializedData);
            }

            return purgeResponse;
        }

        /// <summary>
        /// Purges entire website's cloudflare cache
        /// </summary>
        /// <param name="httpClientFactory"></param>
        /// <returns></returns>
        public static async Task<CloudflarePurgeCacheResponse> PurgeEntireWebsite(IHttpClientFactory httpClientFactory)
        {
            var serializedData = JsonConvert.SerializeObject(@"{""purge_everything"":true}");

            return await Purge(httpClientFactory, serializedData);
        }

        /// <summary>
        /// Purges cloudflare cache using the serializedData
        /// </summary>
        /// <param name="httpClientFactory"></param>
        /// <param name="serializedData"></param>
        /// <returns></returns>
        private static async Task<CloudflarePurgeCacheResponse> Purge(IHttpClientFactory httpClientFactory, string serializedData)
        {
            CloudflarePurgeCacheResponse purgeResponse = null;

            var client = httpClientFactory.CreateClient("cloudflarePurgeCache");

            var requestContent = new StringContent(serializedData, Encoding.UTF8, "application/json");

            var response = await client.PostAsync(client.BaseAddress, requestContent);

            response.EnsureSuccessStatusCode();

            var responseBody = await response.Content.ReadAsStringAsync();

            if (!string.IsNullOrEmpty(responseBody))
            {
                purgeResponse = new CloudflarePurgeCacheResponse()
                {
                    CloudflareResponse = JsonConvert.DeserializeObject<CloudflareResponse>(responseBody),
                    CloudflareResponseJSON = responseBody
                };
            }

            return purgeResponse;
        }
    }
}
Composer

This is how you register your new notifications handlers. 

CloudflareComposer.cs:

using SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.NotificationHandlers;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Notifications;


namespace SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.Composers
{
    public class CloudflareComposer : IComposer
    {
        public void Compose(IUmbracoBuilder builder)
        {
            // Register Umbraco notification handlers
            builder.AddNotificationHandler<ContentPublishedNotification, ContentPublishedNotificationHandler>(); // Publish
            builder.AddNotificationHandler<ContentUnpublishedNotification, ContentUnpublishedNotificationHandler>(); // Unpublish
            builder.AddNotificationHandler<ContentDeletedNotification, ContentDeletedNotificationHandler>(); // Deleted
        }
    }
}
Helper

This is a simple helper to return the Cloudflare entity URL.

CloudflareNotificationHelper.cs:

using SampleProjectUmbraco.Common.Extensions;


namespace SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.Helpers
{
    public static class CloudflareNotificationHelper
    {
        public static string GetEntityUrl(string entityUrl)
        {
            return $"https://{UmbracoServicesAccessor.GetSection("Cloudflare:DomainUrl")}{entityUrl}";
        }
    }
}
Notification handlers

Finally, the notification handlers purge the Cloudflare cache by URL when content is published, unpublished or deleted.

ContentPublishedNotificationHandler.cs:

using Azure;
using SampleProjectUmbraco.Common.Extensions;
using SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.CacheHandlers;
using SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.Helpers;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Web;


namespace SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.NotificationHandlers
{
    public class ContentPublishedNotificationHandler : INotificationHandler<ContentPublishedNotification&rt;
    {
        private readonly IUmbracoContextAccessor _umbracoContextAccessor;
        private readonly ILogger<ContentPublishedNotificationHandler&rt; _logger;
        private readonly IConfiguration _configuration;
        private readonly IHttpClientFactory _httpClientFactory;


        public ContentPublishedNotificationHandler(IUmbracoContextAccessor umbracoContextAccessor, ILogger<ContentPublishedNotificationHandler&rt; logger, IConfiguration configuration, IHttpClientFactory httpClientFactory)
        {
            _umbracoContextAccessor = umbracoContextAccessor;
            _logger = logger;
            _configuration = configuration;
            _httpClientFactory = httpClientFactory;
        }


        public void Handle(ContentPublishedNotification notification)
        {
            try
            {
                if (_configuration.GetValue<bool&rt;("Cloudflare:PurgeEnabled"))
                {
                    Task.Run(() =&rt; PurgeCloudflareCacheForPublishedEntities(notification)).GetAwaiter().GetResult();
                }
            }
            catch (Exception ex)
            {
                _logger.LogError($"ContentPublishedNotificationHandler exception, ex.Message: {ex.Message}, ex.StackTrace: {ex.StackTrace}");
            }
        }


        private async Task PurgeCloudflareCacheForPublishedEntities(ContentPublishedNotification notification)
        {
            var uRLs = new List<string&rt;();
            IUmbracoContext context;


            foreach (var publishedEntity in notification.PublishedEntities)
            {
                if (_umbracoContextAccessor.TryGetUmbracoContext(out context))
                {
                    var entity = context.Content.GetById(publishedEntity.Id);


                    if (entity != null)
                    {
                        var entityUrl = Umbraco.Extensions.FriendlyPublishedContentExtensions.Url(entity);


                        if (!string.IsNullOrEmpty(entityUrl))
                        {
                            uRLs.Add(CloudflareNotificationHelper.GetEntityUrl(entityUrl));
                        }
                    }
                }
            }


            var response = await CloudflareCacheHandler.PurgeCloudflareCacheByURLs(uRLs, _httpClientFactory);


            _logger.LogInformation($"ContentPublishedNotificationHandler PurgeCloudflareCacheByURLs has been called called for {string.Join(' ', uRLs)}, responseJSON: {response.CloudflareResponseJSON}");
        }
    }
}

ContentUnpublishedNotificationHandler.cs:

using SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.CacheHandlers;
using SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.Helpers;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Web;


namespace SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.NotificationHandlers
{
    public class ContentUnpublishedNotificationHandler : INotificationHandler<ContentUnpublishedNotification&rt;
    {
        private readonly IUmbracoContextAccessor _umbracoContextAccessor;
        private readonly ILogger<ContentUnpublishedNotificationHandler&rt; _logger;
        private readonly IConfiguration _configuration;
        private readonly IHttpClientFactory _httpClientFactory;


        public ContentUnpublishedNotificationHandler(IUmbracoContextAccessor umbracoContextAccessor, ILogger<ContentUnpublishedNotificationHandler&rt; logger, IConfiguration configuration, IHttpClientFactory httpClientFactory)
        {
            _umbracoContextAccessor = umbracoContextAccessor;
            _logger = logger;
            _configuration = configuration;
            _httpClientFactory = httpClientFactory;
        }


        public void Handle(ContentUnpublishedNotification notification)
        {
            try
            {
                if (_configuration.GetValue<bool&rt;("Cloudflare:PurgeEnabled"))
                {
                    Task.Run(() =&rt; PurgeCloudflareCacheForUnpublishedEntities(notification)).GetAwaiter().GetResult();
                }
            }
            catch (Exception ex)
            {
                _logger.LogError($"ContentUnpublishedNotificationHandler exception, ex.Message: {ex.Message}, ex.StackTrace: {ex.StackTrace}");
            }
        }


        private async Task PurgeCloudflareCacheForUnpublishedEntities(ContentUnpublishedNotification notification)
        {
            var uRLs = new List<string&rt;();
            IUmbracoContext context;


            foreach (var unpublishedEntity in notification.UnpublishedEntities)
            {


                if (_umbracoContextAccessor.TryGetUmbracoContext(out context))
                {
                    var entity = context.Content.GetById(unpublishedEntity.Id);


                    if (entity != null)
                    {
                        var entityUrl = Umbraco.Extensions.FriendlyPublishedContentExtensions.Url(entity);


                        if (!string.IsNullOrEmpty(entityUrl))
                        {
                            uRLs.Add(CloudflareNotificationHelper.GetEntityUrl(entityUrl));
                        }
                    }
                }
            }


            var response = await CloudflareCacheHandler.PurgeCloudflareCacheByURLs(uRLs,_httpClientFactory);


            _logger.LogInformation($"ContentUnpublishedNotificationHandler PurgeCloudflareCacheByURLs has been called called for {string.Join(' ', uRLs)}, responseJSON: {response.CloudflareResponseJSON}");
        }
    }
}

ContentDeletedNotificationHandler.cs:

using SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.CacheHandlers;
using SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.Helpers;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Web;


namespace SampleProjectUmbraco.Core.BusinessLogic.Cloudflare.NotificationHandlers
{
    public class ContentDeletedNotificationHandler : INotificationHandler<ContentDeletedNotification>
    {
        private readonly IUmbracoContextAccessor _umbracoContextAccessor;
        private readonly ILogger<ContentDeletedNotificationHandler> _logger;
        private readonly IConfiguration _configuration;
        private readonly IHttpClientFactory _httpClientFactory;


        public ContentDeletedNotificationHandler(IUmbracoContextAccessor umbracoContextAccessor, ILogger<ContentDeletedNotificationHandler> logger, IConfiguration configuration, IHttpClientFactory httpClientFactory)
        {
            _umbracoContextAccessor = umbracoContextAccessor;
            _logger = logger;
            _configuration = configuration;
            _httpClientFactory = httpClientFactory;
        }


        public void Handle(ContentDeletedNotification notification)
        {
            try
            {
                if (_configuration.GetValue<bool>("Cloudflare:PurgeEnabled"))
                {
                    Task.Run(() => PurgeCloudflareCacheForDeletedEntities(notification)).GetAwaiter().GetResult();
                }
            }
            catch (Exception ex)
            {
                _logger.LogError($"ContentDeletedNotificationHandler exception, ex.Message: {ex.Message}, ex.StackTrace: {ex.StackTrace}");
            }
        }


        private async Task PurgeCloudflareCacheForDeletedEntities(ContentDeletedNotification notification)
        {
            var uRLs = new List<string>();
            IUmbracoContext context;


            foreach (var deletedEntity in notification.DeletedEntities)
            {
                if (_umbracoContextAccessor.TryGetUmbracoContext(out context))
                {
                    var entity = context.Content.GetById(deletedEntity.Id);


                    if (entity != null)
                    {
                        var entityUrl = Umbraco.Extensions.FriendlyPublishedContentExtensions.Url(entity);


                        if (!string.IsNullOrEmpty(entityUrl))
                        {
                            uRLs.Add(CloudflareNotificationHelper.GetEntityUrl(entityUrl));
                        }
                    }
                }
            }


            var response = await CloudflareCacheHandler.PurgeCloudflareCacheByURLs(uRLs, _httpClientFactory);


            _logger.LogInformation($"ContentDeletedNotificationHandler PurgeCloudflareCacheByURLs has been called called for {string.Join(' ', uRLs)}, responseJSON: {response.CloudflareResponseJSON}");
        }
    }
}

Final Notes

In this final article, I have walked you through setting up Cloudflare page rules to cache your HTML pages together with your front-end assets while using the Cloudflare API to purge your Cloudflare cache for your Umbraco pages after you change them. As the Cloudflare API is just a REST API, you can make similar calls from any website and purge the Cloudflare cache as you need to reflect your changes immediately. 

References

https://www.cloudflare.com/en-gb/what-is-cloudflare/

https://www.cloudflare.com/en-gb/application-services/products/

https://developers.cloudflare.com/learning-paths/get-started/ 

https://servebolt.com/help/article/guide-to-html-full-page-caching-with-cloudflare-for-wordpress/

https://www.youtube.com/watch?v=hHaPgfyxIvY

Nurhak Kaya

Hi, my name is Nurhak Kaya. I am a Technical Architect and a Lead Developer. I am a Umbraco Certified Master and Umbraco MVP. I am also a member of the Umbraco CMS Community Team.

I've been working with Umbraco since 2014. I have had a chance to work with various versions of Umbraco (v4, v6, v7, v8, v9 and v10). I love the product and the community very much, hence I try to contribute to this amazing project as much as possible in any way I can.

I constantly learn more about Umbraco by generally doing some coding challenges like #100DaysOfCode, or #30DaysOfCode, and share my knowledge with the rest of the community. I always keep an eye on the Umbraco questions on StackOverflow (https://stackoverflow.com/users/1587012/nurhak-kaya) or Umbraco Forum (https://our.umbraco.com/members/NurhakKaya/) so that I could help people and learn from them.

I have been writing blogs since 2012 (https://nurhak-kaya.blogspot.com/), I write for the 24 Days in Umbraco, too. 

comments powered by Disqus