Building a website for my content: modern web DevSecOps practices explained

Scope and purpose

I built my website to have an open platform to present myself and the work I do. A static website — where the website is only updated when I decide to write something new — is enough to do the job. All the content I publish is written by me, is public, and equally accessible to everybody.

I share most of the philosophy behind Fabien Sanglard’s all you need is HTML blog post and David Copeland’s Brutalist Web Design; with few exceptions. Here is a set of design goals and requirements that I set for my website:

  • Accessibility: it should comply with accessibility practices, for example regarding screen readers for vision-impaired users;
  • Complexity: the lower the better, both in terms of structure and deployment strategy;
  • Privacy: it should not track or collect individual user data, just high-level statistics;
  • Speed: it should be fast to load and navigate;
  • Security: it should be deployed and served following modern security guidelines.

Overall structure and domain

I use Jekyll to generate the HTML and Github to host the source code; every time an update is pushed to the git repository the website is automatically rebuilt and updated. I’m currently using Cloudflare Pages to host the website, manage the DNS, and as registrar for the domain.

Speaking of domains, a — very opinionated — decision I had to take early in the design process is whether to serve the website using a naked domain like https://example.com or to use a third-level domain (a.k.a. sub-domain) like https://www.example.com.

There is a good amount of personal preference involved in the decision, but also some technicalities. Followers of the yes-www party raise the following points:

On the other end using a third-level domain like www is by some considered redundant, time-consuming to communicate, and antiquated.

My two cents on the topic is that the arguments of the no-www sound mostly aesthetics and less critical than the ones of the yes-www. The cost of avoiding the possible quirks and annoyances of the naked domain appears to be quite low and I’m fine with it.

It’s also worth mentioning that you can always use the naked domain in your communication material or print if you like the look of it. In fact, if you use a third level like www, you will still need to have a redirect from e.g. https://example.com to https://www.example.com, so your visitors will land on the www site whether they typed in the full www domain or not. To enable this redirect in Cloudflare Pages I had to add a page rule with wildcards. The rule redirects to the www domain any request sent to the naked domain, using status code 301 - Moved Permanently.

With these initial decisions settled, I’ll now go through the list of design goals and requirements in more detail.

Accessibility

To check for potential accessibility problems I started by inspecting my pages using Firefox developer tools. A dedicated section in the tool is dedicated to highlighting problems and pointing toward a solution. Some of the most common issues I found were missing alt text for images and low contrast for text on some backgrounds. In addition, I followed the guidelines from an excellent guide for accessible websites. I checked and fixed issues related to the structure of the DOM, as well as other problems that were not detectable using developer tools.

An open point I still have is how to handle the :focus-visible pseudo-class in CSS. The goal is to style interactive elements that are in focus so that keyboard users can have a clear view of where their pointer is. The heuristic used by the browsers to decide whether to show the styling or not is unfortunately not standardized, and I could not find how to apply (and successfully test) :focus-visible to elements that are not buttons.

Complexity

Given the scope of the website, I was able to highly reduce complexity by enforcing some design decisions from the very beginning.

Cookies

Have you clicked through the cookie consent form here? No? That’s because there are no cookies! No cookies means no dreadful cookie consent form is needed.

Scripts

My website will look the same whether the user has javascript enabled or not. That’s because it doesn’t use any script or javascript, just pure HTML, CSS, and a couple of fonts.

Images

I reduced the image formats to only two types: SVG and AVIF.

They are both open, royalty-free formats for vector (i.e. a logo or a flag) and raster (i.e. a photo) graphics. SVG is developed directly by the W3C so it’s an easy choice for web content. AVIF is instead one of the many raster formats available, but it’s the one with the best compression efficiency at the moment. The drawback is that browser support is not as good as for other formats. However, a growing support rate of 84% of the user base is good enough for my use. Worth mentioning that the following step up in compatibility in the efficiency VS compatibility tradeoff would be WebP — currently standing at more than 97% of the user base — and not JPEG.

Using AVIF allowed me to simplify even further how images are served. The best solution in terms of compatibility and bandwidth use would be to offer both AVIF and WebP formats (compatibility), serving thumbnails of different sizes depending on the screen size of the user (bandwidth use), leaving the option to download the full resolution image via a link on the thumbnail. The drawback is that, for each image, two sets of multiple thumbnails must be prepared, in addition to the original images in AVIF and WebP format. That’s a lot of files.

For my use case, this strategy could have been worth the effort if I were using JPEGs, but with AVIF the efficiency is soo high that many of the full-resolution images that I use have a weight similar to the corresponding JPEG thumbnail. Increasing the complexity by creating and serving multiple sizes for each image is not worth my effort when the saving is way below 50 KB. Many of the full-size — up to 3000x3000 px — AVIF images I use on my website weigh 50 KB or less. So I only serve one file: the original full-size version. The HTML to serve a single image file is also less verbose than the multi-format, multi-thumbnails alternative.

By saving on the complexity of the image pipeline, I can free up a bit of time to optimize the only file I serve. I usually take extra care cropping and tweaking the AVIF quality level to lower the weight — when I don’t need high details. I use the excellent cavif CLI to convert my PNG or JPEG images into AVIF.

CSS

I use Tailwind CSS, a CSS framework, but I might switch to vanilla CSS in the future. Tailwind is a quite popular tool for CSS and there’s plenty of material available online to read more about it. Here I just want to share a tip on how to use it without installing Node.js. I don’t use Node.js for my website, so I didn’t want to pull in a whole JS dependency tree just to use a CSS framework.

Luckily Tailwind had me covered: they provide a standalone CLI build, a single-file binary that you can use to generate the CSS without any extra dependency.

HTML

I didn’t use any pre-made HTML template; the website structure was created from scratch. I tried to minimize the clutter — for example, avoiding complicated multi-level nests of containers — and keep the HTML DOM clean, maintainable, and simple. Since I built my own structure, I had the freedom to use HTML semantic elements to annotate the content. For example, why use a generic <div> to annotate an article or blog post when there is an <article> element? When no standard HTML element was available I defined a custom element (e.g. <license-section>). There are a couple of guidelines to follow on how to name custom elements, but that’s all you need to worry about before using them. There is no reason anymore to use <div> or <span> elements when more descriptive and compact custom elements are available, so I didn’t use any on my website.

Finally, I used the W3C Markup Validation Service to check the validity of the HTML markup.

Privacy

I don’t track visitors in any way. No cookies mean also no tracking cookies. I can access some high-level statistics from the dashboard of the hosting service like the number of visitors per day, amount of data served, number of requests, and visitors per country, but nothing more detailed than that.

Speed

Having a minimal structure, no cookies, no scripts, and AVIF images means a fast website pretty much out of the box.

Despite this, I experimented a bit on how to load the fonts; loading fonts for the web can be done in a surprisingly high number of ways. Usually, the tradeoff is between showing the content as quickly as possible and avoiding layout shifts or FOUC, FOUT (Flash of Unstyled Content or Text). Since I had a bit of slack on the performance side, I decided to spend a few milliseconds to load the font face together with the CSS, thus avoiding FOUT — that I didn’t like at all. There are probably better ways to preload web fonts, so I might revise this strategy in the future, most likely when converting to vanilla CSS. Another option is to use a modern font stack and avoid loading external fonts altogether.

Another method I use to save bandwidth and increase speed is to defer loading images until they are close enough to the viewport. For example, the images at the end of a blog post will only be loaded when the user scrolls the page down enough, but still before reaching them with the viewport. This can be done by setting the loading = lazy attribute on <img> elements.

Security

In terms of security, I didn’t change anything specific about the construction or content of the website. The simple structure and the lack of sections where I need to handle information submitted by the users help limit the attack surface. The majority of the work was done by tweaking the configuration on the dashboard of Cloudflare Pages, where my website is hosted.

Please note that some reasonably good security settings are already enabled by default in Cloudflare Pages and I will not mention them below. For example, automatic HTTPS rewrites to limit mixed content errors and the ability to use TLS 1.3 when available.

If you don’t use Cloudflare Pages, you might need to take extra steps to secure your website. Here are some good resources that can get you started:

Below I’ll go through all the measures I implemented for my website, in decreasing order of security benefit.

HTTPS

Nowadays there is no excuse for not using HTTPS, everywhere. You can easily get a free certificate and for many hosting services is a one-click-and-forget solution.

Here are some additional settings related to HTTPS I modified on Cloudflare Pages, I left untouched the rest.

  • SSL/TLS encryption mode to Full (strict): to encrypt end-to-end up to the origin server, where the website is hosted;
  • SSL/TLS Recommender ON: to get an email if a different setting will become the recommended one in the future;
  • Always use HTTPS ON: to reply with a redirect to the https URL when an http URL is requested;
  • Minimum TLS Version TLS 1.2: Mozilla recommends TLS 1.2 as a good compromise between security and compatibility with old clients. Older versions are considered insecure.

HSTS

Even with HTTPS enabled, your users are still vulnerable to MITM attacks when the first request is made via an http:// URL instead of https://. That first request can be intercepted and an offender can potentially take control of the session and redirect the user to a website under their control.

Given that we already support HTTPS everywhere there is no real need to use HTTP for the first request. However, the browser doesn’t know that in advance and just follows the http:// URL typed in by the user. To fix this problem we need a mechanism to preemptively inform the browser that the first request to our website should use HTTPS, even if the user types an http:// link in the address bar.

HTTP Strict Transport Security (HSTS) was created for this purpose. The idea is that we can submit our website to be included in a list of HTTPS-only websites that is included in the browser itself, the HSTS preload list. If the browser is pointed at any of the websites in the preload list it will only send requests using HTTPS to that domain, even if the user types an http:// URL in the address bar.

I enabled HSTS support in Cloudflare Pages using the following settings:

  • Status ON
  • Max-Age 12 Months
  • Include subdomains ON
  • Preload ON

This configuration completes the requirements needed to submit a request for our website to be included in the HSTS preload list. This is done via the official HSTS preload website where we can check and confirm that our configuration is correctly in place, submit the domain for inclusion in the preload list, and get confirmation when it has been included. You can also download the HSTS preload list and check manually if your domain is in it.

HTTP Headers

When responding to an HTTP request from the user, the server is allowed to pass some extra information in addition to the visible content displayed by the browser. This information is used by the browser to better process and control the data received, among other things. HTTP headers are commonly used to transfer this kind of information. These headers are present in both HTTP requests and responses, however, I will only address the responses since that’s what is relevant when configuring our website.

There are several categories of HTTP headers. For my website, I focused on security except for the policy on how to handle referrer information.

But let’s start from the beginning. Out of the box, the server already provides some default headers. To check what was already in place and what could be improved I used the tools listed below. I recommend testing your website with all of them since they complement each other.

  • Mozilla Observatory: scores your HTTP headers and provides links to additional security tests available online. Check the FAQ to make sure you don’t miss anything that can improve your score.
  • Probely Security Headers: grades your HTTP headers and describes each of them in simple terms. It also recommends how to improve your configuration.
  • Google CSP Evaluator: this tool analyzes only the Content-Security-Policy (CSP) security header. It’s useful to spot misconfiguration on CSP and provides a comprehensive description of the problems found and possible solutions.

Let’s now go through my headers configuration. With Cloudflare Pages, we can customize the headers by creating a _headers file that will be used by the server once we deploy the website. I included the following headers, the format is [header_name]: [directive(s)].

Referrer-Policy

Directive: strict-origin-when-cross-origin

This header controls how much information is sent when the user follows an external link or the browser loads an external resource. For example, if I have a blog post with a link to www.external-website.com and the user clicks on that link, the external-website will see that the user is coming from my blog. It is possible to not send any information at all, or restrict based on the security of the destination (e.g. HTTP instead of HTTPS). The choice should consider the security implications — like sending data over insecure HTTP connections — but it is also a matter of privacy and personal preference.

X-Content-Type-Options

Directive: nosniff

With this header, I’m prohibiting the browser to guess the type of content it is downloading. This measure is required to prevent the browser from incorrectly treating a resource as executable (e.g. as a script), which can lead to executing arbitrary content, with unpredictable consequences. For security reasons, we instruct the browser to avoid guessing and to always respect the MIME type of the resource.

Permissions-Policy

Directives: accelerometer=(), ambient-light-sensor=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), serial=(), usb=(), web-share=(), xr-spatial-tracking=()

With this header, we can grant or deny the use of specific browser features like access to geolocation, and the use of the camera, or the microphone. By defining a set of global rules that are valid for all the content served to the users we can put in place safeguards preventing access to sensitive user data, even when we don’t have full control of the code served by the website — i.e. if we serve third party content together with our own. Since I don’t need access to geolocation, microphone, camera, or USB ports I disabled all these permissions together with several others. Some of these permissions are considered experimental at the moment so I only disabled a subset that is stable and with decent browser support.

Cross-Origin-Opener-Policy

Directive: same-origin

Or COOP is a response header that allows us to isolate our document from external attackers. It is one of the mechanisms used to mitigate and prevent cross-origin attacks known as XS-Leaks. The same-origin directive of COOP is the most restrictive option and prevents an external website to access the metadata of our website if they were to open it in a popup or a new window.

Cross-Origin-Resource-Policy

Directive: same-origin

Or CORP is a response header that limits the origins that are allowed to include the resource being served. The same-origin directive only allows a resource to be loaded by the same origin of the original document. This setting provides a defense against attacks like Spectre and it is an additional protection layer on top of the default same-origin policy. It can also help protect against external websites embedding or loading our content (e.g. hotlinking images).

Cross-Origin-Embedder-Policy

Directive: require-corp

Or COEP is a response header that allows us to specify how cross-origin resources can be embedded into our document. The require-corp directive restricts the default setting that is used by the browser, requiring every cross-origin resource to have a permissive CORP header set to be loaded. Meaning that — in my case — the browser will refuse to load any external resource that does not have a cross-origin directive set in the CORP header.

Access-Control-Allow-Origin

Or ACAO is one of the headers used in the Cross-Origin Resource Sharing (CORS) mechanism for HTTP-based access control. It allows our resources to be loaded even when the request comes from an external origin. It is one of the ways of relaxing the default same-origin policy. However I don’t need to provide access to external origins, I want my content to be available to my visitors, thus nothing more permissive than what’s already allowed by the default same-origin policy. Since Cloudflare Pages apparently enables this header by default, I had to explicitly remove it to fall back to the default behavior of the same-origin policy. Notice how this header has a similar function to CORP. While the ACAO header can be omitted, CORP is a more recent addition and should always be defined. If you need to enable ACAO be sure to check how it interacts with CORP.

Content-Security-Policy

Directives: base-uri 'self'; form-action 'none'; frame-ancestors 'none'; default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self'

Or CSP is a response header to more finely control the resources being fetched by the browser when loading our website. We can prevent the browser from loading malicious assets by defining an allow-list of sources and asset types that the browser is authorized to fetch. For example, we can tell the browser that it can load images from a CDN, stylesheets only from our own domain and it is not allowed to load any script at all. CSP helps guard against cross-site scripting attacks, which is on the podium in the OWASP Top 10 list of most common web app vulnerabilities for 2021.

For my website I resorted to a default behavior that disallows loading any type of resource accessible via a fetch directive — no matter where the resource is hosted — except the ones explicitly listed in the allow-list. At the moment I only need images, stylesheets, and fonts.

I added a few other directives to restrict the URLs that can be used as a target for form submission (form-action), to define the URL that can be used for the <base> element (base-uri), and to prevent my pages to be rendered on an external website, for example inside an <iframe> (frame-ancestors). This last directive prevents click-jacking attacks where the user is led to believe to be browsing my website, while all the clicks and interactions are captured by an external attacker controlling the website where the offending <iframe> is deployed. If we look back for a second at CORS and the ACAO header, that mechanism allowed to be more permissive than the default same-origin policy, while the frame-ancestors directive of CSP allows to be more restrictive and more specific.

Here is my complete _header file:

! Access-Control-Allow-Origin

X-Content-Type-Options: nosniff

Referrer-Policy: strict-origin-when-cross-origin

Permissions-Policy: accelerometer=(), ambient-light-sensor=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), serial=(), usb=(), web-share=(), xr-spatial-tracking=()

Content-Security-Policy: base-uri 'self'; form-action 'none'; frame-ancestors 'none'; default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self'

Cross-Origin-Opener-Policy: same-origin

Cross-Origin-Embedder-Policy: require-corp

Cross-Origin-Resource-Policy: same-origin

DNS

Cloudflare allows enabling of a security protocol called DNS Security Extensions (DNSSEC). The DNS protocol is more than 35 years old and was not designed with security in mind, with DNSSEC we can mitigate some of the security shortcomings of DNS. I enabled DNSSEC from the Cloudflare dashboard under DNS settings.

Conclusions

I want to finish with a couple of remarks on the costs involved in publishing my personal website and the results I got when testing the website using many of the tools discussed above.

Costs

I don’t need any feature outside of what’s included in the free plans for both Cloudflare Pages and Github. The cost for hosting the website and the source code is zero, for now. I only pay the annual fee to register the domain, but Cloudflare offers it at cost with no markup, so I really cannot spend anything less than what I’m paying now.

If you want to keep the costs to absolute zero you can drop the use of a personal domain and use a sub-domain that many hosting services provide for free. You will have a URL that would probably look like https://my-personal-blog.provider.com. I do not recommend this solution unless you really cannot afford a personal domain. However I think it’s still better than putting your content and your audience in the hands of a platform that sooner or later will take actions affecting your content or your audience, actions you might not fully agree with.

Results and scores

I will spare you the celebratory screenshots, but below is a recap of the scores of the various tests I performed. You can verify and repeat the same tests by using for example my About page as a URL.