This could have just been an S3 website.

But adding all this excessive tech was pretty fun.

Check it out.

You can view the full code that provisioned and configured this website on github. Feel free to use it or modify it for your own needs.

Here's an overview of the stack

Amazon ECS
Amazon's EC2 Container Service is pretty straightforward to use. I prefer setting up configuration and autoscaling manually, as opposed to Elastic Beanstalk. In this configuration, I am using a single EC2 instance with no autoscaling. The EC2 instance is manually joined to the cluster using the UserData script:
#!/bin/bash -x
  
mkdir -p /etc/ecs
echo 'ECS_CLUSTER=${cluster}' >> /etc/ecs/ecs.config
It's a little bit hacky, but this is the recommended way of doing it.
Ansible
Ansible is a declarative configuration management tool which excels at managing mutable infrastructure on a large scale. In an ideal world, all infrastructure would be immutable, but I find that that rarely happens in production.
A good example of this is the LetsEncrypt integration using acme.sh and the acme.sh role. Here are the guts of the acme.sh ansible role:
- name: Issue certificates
  command: >
    /root/.acme.sh/acme.sh --accountemail {{ admin_email }}
      --issue --standalone --httpport {{ acme_nc_port }}
      -d {{ ' -d '.join(tls_hostnames) }}
      --key-file {{ key }}
      --fullchain-file {{ fullchain }}
      --reloadcmd 'umask 027 && cat {{ fullchain }} {{ key }} >{{ combined }} && chown root:haproxy {{ combined }} && service haproxy restart'
  args:
    creates: '{{ fullchain }}'
  notify:
    - haproxy-tls
Breaking this down, it is executing a shell command, `/root/.acme.sh/acme.sh`, with a series of switches. The `>` operator allows long single line strings to wrap seamlessly. (Dat significant whitespace.)
  args:
    creates: '{{ fullchain }}'
This means that the command will create a file at the path provided by the variable `fullchain`. In effect, it makes the task idempotent, because it will not run again once it has successfully created a fullchain file.
Finally, it will notify the HAProxy daemon using a series of handlers:
- name: haproxy-tls
  lineinfile:
    path: /etc/haproxy/haproxy.cfg
    regexp: '^.*bind.*:443'
    line: 'bind *:443 ssl crt /etc/haproxy/tls'
  notify:
    - haproxy

- name: haproxy
  service:
    name: haproxy
    state: restarted
This looks a bit more complicated than it is. Because acme.sh relies on HAProxy to route the .well-known challenge, HAProxy must be able to run before a certificate has been issued. This series of notifiers uncomments the HTTPS listener, and then restarts the HAProxy daemon.
In the event that this was immutable infrastructure, the TLS certificates would be lost for every change. You would quickly run up against the LetsEncrypt rate limits. You could potentially store the certificates (hopefully encrypted) in some kind of persistent storage, but the complexity of the stack would increase dramatically.
Docker
Docker makes deploying Flask apps to production incredibly trivial. This would have been a lot more difficult without tiangolo's uWSGI NGINX Flask docker image.

Essentially, all I have to do to build my docker image is copy my code inside, provide it with configuration, and install my app's requirements.
Flask
Flask is by far my favorite web framework. It's clean, fast, has none of the complicated hacks that come with larger MVC frameworks, and it can more or less do everything.

The main complication I had with writing the app is that I am using a non-standard directory tree due to running inside Docker. It made relative imports fairly difficult. Since `app` is actually declared in main.py instead of __init__.py, anything that needs to import both the module namespace and app can easily end up importing things out of order and crash.
Haproxy
HAProxy is an excellent load balancer/proxy solution. In this case, it acts as a listener for both LetsEncrypt ACME challenges, and web traffic. Here's the custom configuration portion of haproxy.cfg:
listen webserver
    bind *:80
    {{ '' if san_tls.stat.exists else '# ' }}bind *:443 ssl crt /etc/haproxy/tls # This gets enabled after certs are issued

    acl letsencrypt-acl path_beg /.well-known/acme-challenge/
    acl api-acl path_beg /api

    redirect scheme https code 301 if !{ ssl_fc } !letsencrypt-acl !api-acl

    use_backend acme.sh if letsencrypt-acl
    server docker localhost:{{ docker_port }}

backend acme.sh
    server acme-standalone localhost:{{ acme_nc_port }}
You can see a little bit of a hack toward the beginning using jinja2 templating. Since HAProxy is acting as a frontend for LetsEncrypt validation, it has to be able to run before the TLS certificates have been issued. So it starts out with the TLS listener commented out, and uncomments it once the SAN TLS certificate exists.

Otherwise, it's a pretty standard setup. It redirects all traffic to HTTPS unless it is an ACME challenge or an API request. If it is an ACME challenge, it is redirected to a netcat listener spawned by acme.sh.
LetsEncrypt
It's $current_year. All web traffic should be TLS encrypted. LetsEncrypt came along at the perfect time. You can issue free TLS certificates for your websites, and it's literally zero maintenance. Check out their documentation.

I strongly recommend acme.sh if you are going to implement LetsEncrypt.
Python3
Not much to say here. Python3 makes programming fun again.
SQLAlchemy
Databases are the bane of any critical application's existence. Most of the easily exploitable, catastrophic vulnerabilities in webapps are some form of SQL injection. Beyond that, you can quickly run into major performance issues by forming queries inefficiently.

SQLAlchemy is a Python framework that makes interaction with databases trivial. Instead of typing out a bunch of SELECTs and JOINs, you write models, and you let the magic talk to your database for you. Here's a model declaration (which is also a table schema declaration):
class Guest(db.Model):
    """Model for guestbook API"""
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    name = db.Column(db.String(32), unique=True)
    timestamp = db.Column(db.DateTime, default=datetime.datetime.now())
This isn't a whole lot simpler than just declaring it using SQL. However...
    guest = Guest(name=name)
    db.session.add(guest)
    db.session.commit()
There's just no way to make it easier than this. I don't have to worry about string safety, forming queries dynamically, or anything.
SQLite
SQLite doesn't really have a place in Enterprise webapps. But for a project like this, it's mostly a drop-in replacement for a more scaleable solution like Postgres or MariaDB.
Terraform
Terraform is a fairly new technology to me. I've been using AWS Cloudformation, but have consistently run into some pretty serious limitations. Given a choice between these two, I would certainly choose Terraform.

The main advantage to Terraform, is that it's really fast. I can still declare all the same resources in a similar way to Cloudformation, but I don't have to worry about the stack becoming stuck in an unstable state for 3 hours.

Having said that, it has some limitations of its own. The main issue I have run into so far is lack of loop control or standard programming operators. For example:
data "template_file" "cloud-config-users" {
    template = "${file("${path.module}/templates/user.tpl")}"
    vars {
        name = "${lookup(var.users[count.index], "name")}"
        pubkeys = "${jsonencode(split(",", lookup(var.users[count.index], "pubkeys")))}"
    }
    count = "${length(var.users)}"
}
...
data "template_cloudinit_config" "config" {
    part { content = "${data.template_file.cloud-config-users.rendered}" }
    count = "${var.number}"
}
"count" is the rough equivalent of loop control in Terraform. Here, I can create an arbitrary number of users with SSH access. But what if I need 0? A standard for loop makes this so much easier.
acme.sh
acme.sh is the best shell script I've ever used. It makes interacting with the LetsEncrypt API trivial.

Once you get it set up, it's more or less completely hands off. Your TLS certificates will be renewed automatically once every 60 days.

In this case, since I am using it with HAProxy, my configuration is a bit more complicated.
/root/.acme.sh/acme.sh --accountemail {{ admin_email }}
      --issue --standalone --httpport {{ acme_nc_port }}
      -d {{ ' -d '.join(tls_hostnames) }}
      --key-file {{ key }}
      --fullchain-file {{ fullchain }}
      --reloadcmd 'umask 027 && cat {{ fullchain }} {{ key }} >{{ combined }} && chown root:haproxy {{ combined }} && service haproxy restart'
It's running in standalone mode, which means it will use its own webserver (netcat) to listen for replies. I've got HAProxy in front of it, directing traffic from public port 80 to the localhost listener.

Jinja2 dynamically compiles a list of names for the SAN certificate.

The reloadcmd directive tells it what actions to perform when a certificate is renewed. There's really no limitation to what you can do here. Since uptime is not critical for me, I've elected to just concat the key and chain together, and then restart HAProxy.