Micah Jamison's Dev Blog.

# Deploy a FastHTML App to AWS using Terraform and Ansible I had a very outdated ubuntu server on digitalocean that I was using for hosting my blog, git repos, and other random things like a minecraft server for the kids. None of my server setup was documented or easily reproducable so setting up a new server was a chore I could never seem to get around to tackling. Recently I've needed to improve my terraform skills and decided the time was right to start from scratch and set things up the right way. Desired End State: - AWS ubuntu ec2 instance - python uvicorn app (specifically fasthtml) - Digital cert Desired Tools: - terraform - ansible First step was finding blog posts and examples of using the tools I wanted to achieve my desired end state. I had trouble finding anything that exactly fit the bill as many examples didn't start from a brand new aws environment or did things I found odd like provisioning a single ubuntu server just to run a single docker container inside of it. I ended up cherry-picking examples from a few sources and got something that worked for my needs. ## Step one, initial AWS setup and server provisioning Create a new ssh key to use for connecting to a new server. ```sh ssh-keygen -t rsa -f thekey.rsa ``` Create a file called main.tf that will be used to setup a new aws environment and ec2 server instance. I didn't expect to be writing a post about this so failed to take screenshots of all the steps like how to find your VPC id, but those should be easy to google. Terraform will reference the key from the previous command so that it can be used to connect to the server. ```yaml provider "aws" { region = "us-west-2" } resource "aws_security_group" "my_sg" { name = "my-ssh-sg" description = "Security group for public access" vpc_id = "vpc-<your VPC id>" # Replace with your VPC ID } resource "aws_security_group_rule" "ssh_inbound" { type = "ingress" # Inbound rule security_group_id = aws_security_group.my_sg.id # Security group ID from_port = 22 # SSH port to_port = 22 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] # Allow SSH from any IP (NOT recommended for production) } resource "aws_security_group_rule" "https_inbound" { type = "ingress" # Inbound rule security_group_id = aws_security_group.my_sg.id # Security group ID from_port = 443 # SSH port to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] # Allow SSH from any IP (NOT recommended for production) } resource "aws_security_group_rule" "http_inbound" { type = "ingress" # Inbound rule security_group_id = aws_security_group.my_sg.id # Security group ID from_port = 80 # SSH port to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] # Allow SSH from any IP (NOT recommended for production) } resource "aws_security_group_rule" "outbound" { type = "egress" # Outbound rule, allows apt update security_group_id = aws_security_group.my_sg.id # Security group ID from_port = 0 # SSH port to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } data "aws_ami" "ubuntu" { most_recent = true filter { name = "name" values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04*"] } filter { name = "virtualization-type" values = ["hvm"] } filter { name = "architecture" values = ["x86_64"] } owners = ["099720109477"] #canonical } locals { instances = { instance1 = { ami = data.aws_ami.ubuntu.id instance_type = "t2.micro" } } } resource "aws_key_pair" "thekey" { key_name = "thekey" public_key = file("thekey.rsa.pub") } resource "aws_instance" "this" { for_each = local.instances ami = each.value.ami instance_type = each.value.instance_type key_name = aws_key_pair.thekey.key_name associate_public_ip_address = true security_groups = [ "my-ssh-sg" ] tags = { Name = each.key } } output "aws_instances" { value = [for instance in aws_instance.this : instance.public_ip] } ``` Get an aws access key with enough permissions to create things in your aws account. I ended up creating a new user with admin access. You can then set the environment variables and run the terraform commands to apply the main.tf above. ```sh export AWS_ACCESS_KEY_ID="<access key>" export AWS_SECRET_ACCESS_KEY="<secret>" export AWS_DEFAULT_REGION=us-west-2 terraform init # only needed first time in same directory as main.tf terraform plan terraform apply ``` The above command will output the IP of your newly created server. Create a file called inventory.ini to store it. Having an inventory.ini is a nice way to store a list of IP addresses in case you need to setup multiple servers the same way using ansible. ```ini [all] 35.91.197.139 # replace with IP from terraform apply ``` ## Setup the new server to serve static files Registering a new digital certificate requires having a server that just serves a static file that the cert provider will give during the verification process. I first updated my DNS record to point my existing domain name to the IP from the previous step. Next I created an ansible playbook to install an nginx server and run it. index.html.j2 ```html <html> <body> Hi there! </body> </html> ``` nginx_static.j2 ```lua server { listen 80; server_name dataconcise.com; -- replace with your domain root /var/www/html; index index.html; location / { try_files $uri $uri/ =404; } } ``` install_nginx.yaml ```yaml --- - name: Install nginx hosts: all become: true tasks: - name: Update apt cache apt: update_cache: true - name: install required system packages apt: name: - curl - vim - nginx state: present - name: Create directory for static content file: path: /var/www/html state: directory mode: 0755 - name: Create "index.html" file with "hello world" content template: src: "index.html.j2" dest: /var/www/html/index.html mode: 0644 - name: Create default nginx config template: src: "nginx_static.j2" dest: /etc/nginx/sites-available/default mode: 0644 - name: start nginx systemd: state=started name=nginx daemon_reload=yes ``` Now you can run the following command to apply these changes to your new server: ```sh ansible-playbook --private-key=thekey.rsa -i inventory.ini -u ubuntu install_nginx.yaml ``` To test if this worked as expected go to http://<your domain> and you should see the content from index.html.j2. Most browsers will give an insecure warning which is expected as a digital certificate isn't being used yet. I needed to create yet another rsa key to provide my digital cert provider before they would issue a new certificate: ```sh openssl genrsa -out dataconcise.key 2048 openssl req -new -key dataconcise.key -out dataconcise.csr ``` Now you should be able to copy the verification file that your certificate creator provided to prove that you own the domain: ```sh scp -i "thekey.rsa" ~/Downloads/<file from cert provider> ubuntu@35.91.197.139:~/ ssh -i "thekey.rsa" ubuntu@35.91.197.139 cd /var/www/html mkdir -p <the subpath the provider wants the file> cp ~/<the file> <new directory> exit ``` Once the certificate provider is able to access the file, they should allow you to download a new certificate. I needed to create a certificate bundle using cat ```sh cat <multiple files> > ssl-bundle.crt ``` ## Digital certificate setup Next create another ansible playbook to setup nginx to use the new digital certificate. nginx_site.j2 ``` server { listen 443; server_name dataconcise.com; ssl on; ssl_certificate /etc/ssl/certs/ssl-bundle.crt; ssl_certificate_key /etc/ssl/private/dataconcise.key; ssl_prefer_server_ciphers on; root /var/www/html; index index.html; location / { try_files $uri $uri/ =404; } } ``` install_certs.yaml ```yaml --- - name: Install certs hosts: all become: true tasks: - name: Copy ssl certs copy: src: dataconcise_certs/ssl-bundle.crt dest: /etc/ssl/certs/ssl-bundle.crt owner: root group: root mode: 0755 - name: Copy cert key copy: src: dataconcise.key dest: /etc/ssl/private/dataconcise.key owner: root group: root mode: 0755 - name: Create default nginx config template: src: "nginx_site.j2" dest: /etc/nginx/sites-available/default mode: 0644 - name: start nginx systemd: state=started name=nginx daemon_reload=yes ``` ```sh ansible-playbook --private-key=thekey.rsa -i inventory.ini -u ubuntu install_certs.yaml ``` You should be able to visit your domain using https (no more insecure site warning!). ## Setup python web hosting Now to connect all the dots and setup a python uvicorn server with nginx as a reverse proxy. nginx_blog_proxy.j2 ```lua server { listen 443; server_name dataconcise.com; ssl on; ssl_certificate /etc/ssl/certs/ssl-bundle.crt; ssl_certificate_key /etc/ssl/private/dataconcise.key; ssl_prefer_server_ciphers on; location / { proxy_pass http://localhost:8000; # Replace 8000 with your FastAPI port proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; proxy_read_timeout 600; } } ``` blog.service.j2 ```ini [Unit] Description=blog After=network.target # Requires=postgresql.service [Service] Type=simple User=ubuntu Group=ubuntu DynamicUser=true WorkingDirectory=/home/ubuntu/blog PrivateTmp=true ExecStart=/home/ubuntu/venv/fasthtml_env/bin/uvicorn \ --proxy-headers \ --forwarded-allow-ips='*' \ --workers=2 \ --host=0.0.0.0 \ --port=8000 \ --no-access-log \ main:app ExecReload=/bin/kill -HUP ${MAINPID} RestartSec=1 Restart=always [Install] WantedBy=multi-user.target ``` install_python_app.yaml ```yaml --- - name: Install python app hosts: all become: true tasks: - name: Create default nginx config template: src: "nginx_blog_proxy.j2" dest: /etc/nginx/sites-available/default mode: 0644 - name: start nginx systemd: state=started name=nginx daemon_reload=yes - name: install python apt: name: - python3 - python3-pip - python3-venv state: present - name: Blog setup hosts: all tasks: - name: Create virtual environment shell: "python3 -m venv /home/ubuntu/venv/fasthtml_env" args: creates: /home/ubuntu/venv/fasthtml_env - name: Copy blog copy: src: /home/micah/projects/blog/ dest: /home/ubuntu/blog owner: ubuntu group: ubuntu mode: 0755 - name: install blog reqs pip: requirements: /home/ubuntu/blog/requirements.txt virtualenv: /home/ubuntu/venv/fasthtml_env virtualenv_command: python3 -m venv - name: enable service hosts: all become: true tasks: - name: Configure uvicorn service template: src: blog.service.j2 dest: /etc/systemd/system/blog.service owner: root group: root mode: 0644 - name: start myservice systemd: state=started name=blog daemon_reload=yes ``` ```sh ansible-playbook --private-key=thekey.rsa -i inventory.ini -u ubuntu install_python_app.yaml ``` It's likely that I failed to document a few steps so if there is anything missing please let me know at dataconcise@gmail.com. @blog