A few people lately have been asking me about my blogging setup, so I’ve decided to write a post to explain what’s behind the scenes of this website. In a further post I will also tackle the setup for cloudseclist.com.

Given the interest raised, and the multiple questions I've received around how much this setup cost me, this post has been updated on to expand on the cost of each of the services I rely upon.

A Brief History of marcolancini.it

I’ve had a personal website since 2003, and, as you can see from the screenshots below, web design has always been my strongest skill (πŸ€¦β€β™‚οΈ).

Over the years, I went through a few iterations, and for a period I even ran on Wordpress (😱).

The only constant was that I’ve always used a niche hosting provider from Italy, which only offered support for static websites and Wordpress (hence why I was once crazy enough to move everything to it, only to run back to static websites shortly after). Additionally, the provider only offered TLS certificates as a β€œpremium” feature, costing double the price just for being able to serve a website via HTTPS. Even better, the only way to publish a new version of the website (new post, etc.), was to manually upload the content via FTP. Yep, you’ve read it right: clear, plain text FTP.

This was probably fine in 2003 but not nowadays. To me it looked a lot like:

The Phoenix Project
The Phoenix Project

If you don’t recognize this image, it is from the cover of The Phoenix Project (and if you haven’t read it, you really should!).

So, roughly two years ago (summer 2018) I got fed up with this and decided to move everything to the cloud…


The AWS Setup

…and it was incredible. Nowadays, marcolancini.it is a static website hosted in an S3 bucket and deployed automatically via GitHub actions. The setup looks more or less like the diagram below.

The AWS Setup
The AWS Setup

Let’s analyze the different components from this picture.

Domain and DNS

Although the domain names for both marcolancini.it and marcolancini.com are registered with Route53, I’ve found CloudFlare to be more customizable (and cheaper) for DNS management, so from Route53 I pointed the authoritative nameservers to the CloudFlare ones.

Static Hosting

Two S3 buckets host the content of this website:

  • www.marcolancini.it: an S3 bucket configured for static web hosting, and with a bucket policy that allows everyone to read its content.
  • marcolancini.it: another bucket configured for static hosting, but which redirects all requests to the β€œmain” bucket www.marcolancini.it.

With the 2 buckets setup, DNS entries in CloudFlare are configured to point two CNAMEs (marcolancini.it, and www) to the URL of the main bucket:

CNAME marcolancini.it   www.marcolancini.it.s3-website.eu-west-2.amazonaws.com
CNAME www               www.marcolancini.it.s3-website.eu-west-2.amazonaws.com

I also use CloudFlare for its ability to automatically provide free TLS certificates, and, most importantly, for its CDN network to speed up delivery across the globe. Though, I have to admit that I could make some further improvements on the caching configuration.

CloudFlare Caching
CloudFlare Caching

Sending/Receiving Emails

Since I wanted to manage everything from within AWS, I needed a way to be able to both receive and send emails from my @marcolancini.it domain.

For this, I’ve found the setup proposed by aws-lambda-ses-forwarder quite effective (with only a couple of tweakings needed). The README in that repo is quite explicative, but, in short, the process to set up email reception with SES is the following:

  • First of all, I had to verify both the marcolancini.it domain and a forwarding email address (<redacted>@gmail.com) within SES.
  • Then, I had to create an S3 bucket to store incoming emails (let’s call it mailbox-bucket). This bucket has a bucket policy that allows SES to put objects in it, and a lifecycle configured to delete objects after 90 days from creation.
  • The next step involved setting up a lambda (let’s call it SesForwarder) that forwards every incoming email to a destination address (Gmail in my case). This can be obtained by modifying the constants in the index.js file (provided in the aws-lambda-ses-forwarder repo) to fetch emails from mailbox-bucket and forward them to <redacted>@gmail.com.
  • Finally, I had to setup a Reception Rule on SES, with 2 actions performed for every email incoming into SES:
    • S3 action: choose mailbox-bucket. This will allow SES to store the incoming email as an object in the specified S3 bucket.
    • Lambda action: choose the SesForwarder Lambda function. This will trigger the lambda, which, in the end, will forward the email to the destination address.

Setting up outgoing emails was then just a matter of creating an SMTP user in SES, and configuring Gmail to send emails as that SMTP user.


Monitoring

Although I do not collect personally identifiable information, Google Analytics and CloudFlare Statistics allow me to obtain a high-level picture of the readers of this blog. For example, the picture below shows the geographical distribution of readers by country, which for the past month saw the following as the most frequent sources of traffic:

  1. USA
  2. France
  3. UK
  4. Germany
  5. China
Traffic by Country
Traffic by Country

Another aspect I wanted to monitor, given its criticality, was the SesForwarder lambda, since its failure will result in a loss of emails destined to @marcolancini.it.

In addition to CloudWatch Alarms, triggered to fire to any invocation failure, I’ve also decided to run periodic health-checks to ensure emails are still getting forwarded to my personal Gmail address. To do so, I’ve subscribed a couple of email addresses within the @marcolancini.it domain to an SNS topic. Then, I leverage CloudWatch Events to trigger another lambda (SNSTrigger in the diagram above) which simply puts a new message to that SNS topic. If I don’t receive any email at the predefined intervals, I can then be sure something is wrong.

Weekly Check
Weekly Check

Website Generation via Jekyll

Having discussed the overall setup, and how the content is stored and delivered through AWS, it is now time to explain how I generate the website and write new posts.

First of all, I’m a fan of monorepos, so the code for all my websites (well, I mainly have 2 at the moment) is in a single private repository on Github:

❯ tree -L 1 websites/
websites
β”œβ”€β”€ README.md
β”œβ”€β”€ cloudseclist.com
β”œβ”€β”€ marcolancini.com
└── marcolancini.it

3 directories, 1 file

As mentioned previously, I’m going to blog about the setup for cloudseclist.com in another post, whereas here we will be focusing on marcolancini.it:

❯ tree marcolancini.it -L 3
marcolancini.it
β”œβ”€β”€ README.md
β”œβ”€β”€ docker-compose.yml
β”œβ”€β”€ resources
β”‚Β Β  └── setup
β”‚Β Β      β”œβ”€β”€ aws.md
β”‚Β Β      β”œβ”€β”€ diagrams
β”‚Β Β      └── lambda
└── web
    β”œβ”€β”€ Dockerfile
    β”œβ”€β”€ Dockerfile_grunt
    └── site
        β”œβ”€β”€ 404.html
        β”œβ”€β”€ Gemfile
        β”œβ”€β”€ Gemfile.lock
        β”œβ”€β”€ Gruntfile.js
        β”œβ”€β”€ _config.yml
        β”œβ”€β”€ _config_dev.yml
        β”œβ”€β”€ _drafts
        β”œβ”€β”€ _includes
        β”œβ”€β”€ _layouts
        β”œβ”€β”€ _posts
        β”œβ”€β”€ _sass
        β”œβ”€β”€ _site
        β”œβ”€β”€ _templates
        β”œβ”€β”€ assets
        β”œβ”€β”€ images
        β”œβ”€β”€ index.html
        β”œβ”€β”€ node_modules
        β”œβ”€β”€ package-lock.json
        β”œβ”€β”€ package.json
        β”œβ”€β”€ pages
        β”œβ”€β”€ pages_tech
        └── search.json

Since historically my hosting provider only supported static sites, I ended up using Jekyll, a pretty straightforward static site generator written in Ruby by one of GitHub’s co-founders. There are plenty of websites explaining how to setup your blog with Jekyll (in case you were interested in replicating my setup), but definitely a good place to start is the official documentation.

From the directory listing above, you can see the web/site/ folder which contains the Jekyll website (pretty standard format). The custom modifications I’ve made (apart from a custom theme) are related to the way I run Jekyll, which is via custom Docker images coordinated via docker-compose:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
version: '2'
services:
    # ------------------------------------------------------------------------------------
    # GRUNT
    # ------------------------------------------------------------------------------------
    grunt:
        container_name: ml_grunt
        build:
            context: ./web/
            dockerfile: Dockerfile_grunt
        volumes:
            - $PWD/web/site/:/src/website/
        entrypoint: grunt

    # ------------------------------------------------------------------------------------
    # WEBSITE
    # ------------------------------------------------------------------------------------
    web:
        container_name: ml_web
        restart: always
        build:
            context: ./web/
            dockerfile: Dockerfile
        volumes:
            - $PWD/web/site/:/src/website/
        ports:
            - 127.0.0.1:4000:4000
        environment:
              - VIRTUAL_HOST=127.0.0.1
              - VIRTUAL_PORT=4000
        command: jekyll serve --config _config.yml,_config_dev.yml  --host 0.0.0.0 --port 4000
  • Lines 6-13: define a container for Grunt, a JavaScript task runner I mainly use to minify images before publishing them.
  • Lines 21-23: the image for the container running Jekyll comes from a custom Dockerfile (shown below):
FROM jekyll/jekyll:latest

#Β Create workdir
RUN mkdir -p /src/website/
WORKDIR /src/website/

# Cache bundle install
COPY ./site/Gemfile* /src/website/
RUN chmod a+w Gemfile.lock
RUN bundle install
  • Line 25: the web/site/ folder (containing the Jekyll installation) is shared with the container, so I can make changes from VisualStudio Code on my host and have Jekyll automatically pick up the changes and render them.
  • Line 27: port 4000 of the container is exposed to the same port on localhost, so that I can access the preview on http://127.0.0.1:4000.
  • Line 31: the command to run Jekyll, with the _config_dev.yml file used to override the base setup just for local usage.
Local Preview
Local Preview

With this setup, I can add a new markdown file in the web/site/posts/ folder and write the content locally. But how does this ends up published?


Automatic Deployments via GitHub Actions

The final loop in the chain is provided by GitHub actions, which allow me to automatically deploy to S3 every time I push to the master branch.

Here is the content of the workflow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
name: Marcolancini

on:
  push:
    branches:
      - master
    paths:
      - 'marcolancini.it/web/site/*'
      - 'marcolancini.it/web/site/*/*'
      - 'marcolancini.it/web/site/*/*/*'

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout repository
      uses: actions/[email protected]
      with:
        ref: master
        fetch-depth: 1
    - name: Build the site in the jekyll/builder container
      run: |
        docker run \
        -v ${FOLDER}:/srv/jekyll -v ${FOLDER}/_site:/srv/jekyll/_site \
        jekyll/builder:latest /bin/bash -c "chmod 777 /srv/jekyll && jekyll build --future"
      env:
        FOLDER: ${github.workspace}/marcolancini.it/web/site
    - name: Deploy
      run: aws s3 sync ${FOLDER}/_site/ s3://${BUCKET} --delete
      env:
        FOLDER: ${github.workspace}/marcolancini.it/web/site
        BUCKET: www.marcolancini.it
        AWS_ACCESS_KEY_ID: ${secrets.AWS_ACCESS_KEY_ID}
        AWS_SECRET_ACCESS_KEY: ${secrets.AWS_SECRET_ACCESS_KEY}
  • Lines 5-10: the workflow only runs when a file gets modified in the marcolancini.it/web/site/* folder on the master branch. This avoids triggering the pipeline from commits on developing branches.
  • Lines 17-21: the first step simply checks out the repository.
  • Lines 22-28: the second step builds the site in the jekyll/builder container.
  • Line 30: finally, the third step syncs the generated website with the main S3 bucket, using the AWS API keys specified in lines 34-35.
GitHub Action
GitHub Action

How Much Does this Cost?

After I first released this post, there’s been interest around how much this setup costs me every month, since most of the services I use are paid ones. So, let’s do some math!

Let’s start by the monthly cost of AWS services: the table and graph below show my expenditure for the AWS account hosting marcolancini.it for the past 5 months (1st November 2019 - 31st March 2020):

Month Tax($) S3($) SES($) Lambda($) SNS($) CloudWatch($) Total cost($)
Nov 2019 0.13 0.67 0.00 0.00 0.00 0.00 0.80
Dec 2019 0.17 0.86 0.00 0.00 0.00 0.00 1.03
Jan 2020 0.16 0.78 0.00 0.00 0.00 0.00 0.94
Feb 2020 0.18 0.74 0.13 0.00 0.00 0.00 1.05
Mar 2020 0.2 1.00 0.00 0.00 0.00 0.00 1.20
Service Total 0.84 4.05 0.13 0.00 0.00 0.00 5.02
AWS Expenditure for the Past 5 Months
AWS Expenditure for the Past 5 Months

Notice: Data transfer costs are included in the services that they’re associated with, such as Amazon EC2 or Amazon S3. They aren’t represented as either a separate line item in the data table or a bar in the chart.

On average this adds up to ~$1 per month (~Β£0.80 at the current exchange rate). On top of this, we have to add:

  • Domain names registration (marcolancini.it, marcolancini.com): $13.5 each, for a total of $27 per year ($2.25 per month).
  • GitHub actions: $0, since I’m way below the 2,000 minutes per month of the free tier.
  • CloudFlare: $0, since I’m on the free tier.

So, in total, I’m spending $3.25 per month (~Β£2.59), mostly coming from the domain name fees:

Monthly Cost Total($)
AWS Services 1.00
Domain Names 2.25
GitHub Actions 0.00
CloudFlare 0.00
Total 3.25

Conclusion

Thank you for making it this far! I hope you found this post interesting, as it described my workflow for publishing new posts on this website.

In a following post I will tackle the setup for cloudseclist.com, which is a bit more convoluted since it also has a serverless mailer solution based on top off SES!

If something is unclear, or if I’ve overlooked some aspects, please do let me know on Twitter @lancinimarco.