
Part 1 of this series got the Terraform infrastructure ready: an IAM role and instance profile for S3/DynamoDB access, and security group rules opening the ports the KloudGov application actually needs. The infrastructure is ready. Now it’s time to actually deploy something onto it.
This post is deliberately the “hard way.” Before introducing any Ansible automation in Part 3, I deployed the KloudGov application to the Michigan EC2 instance entirely by hand: every download, every config file, every service restart, typed and run manually. The goal isn’t to show off a clever deployment. It’s the opposite: to feel exactly how much effort, repetition, and risk is baked into doing this without automation, so Part 3’s payoff means something concrete instead of an abstract claim.
I’ll admit up front that my first attempt at this used a private Git repo cloned directly onto the target instance, and it turned into unnecessary complexity fast, mostly around per-server deploy key management. So I stepped back and used a simpler, more realistic approach: the application code is packaged as a .zip artifact, hosted in a dedicated S3 bucket, and pulled down from there instead. That’s the version documented below, and it better reflects how a lot of real teams stage deployment artifacts.
What Manual Deployment Actually Costs You
Before touching a terminal, it’s worth naming what’s actually at stake here:
High effort — every step (build, upload, configuration, restart) depends on hand-typed commands or ad hoc local scripts, requiring constant attention.
Risk of human error — a command run out of order, or a forgotten config file, can bring down the entire environment.
Inconsistency between servers — reproducing the exact same state across multiple nodes by hand is genuinely difficult. Small differences accumulate into “ghost” bugs that are miserable to trace.
Longer recovery time — rollbacks and hotfixes become a race against the clock, because everything has to be redone manually, under pressure.
Limited scalability — adding new servers means repeating this entire ritual, one more time, by hand.
Without automation, delivery slows down, reliability drops, and the team spends more time firefighting than building. Keep that in mind as this post goes on; every step below is something Part 3 is going to eliminate.
Prerequisite
This post assumes the infrastructure from Part 1 is already provisioned: the Michigan EC2 instance, its IAM role, and its expanded security group rules.
Step 1: Downloading the KloudGov Application Files
The application code is packaged as a .zip artifact and hosted in a dedicated S3 bucket, kloudgov-deployment-artifacts, created specifically for this deployment walkthrough. Inside the local kloud-gov-application repository, pull that artifact down and extract it into a new src directory:
cd kloud-gov-application # Exclude the zip itself from version control — only the extracted source belongs in Git echo "*.zip" >> .gitignore ls -a cat .gitignore mkdir src cd src wget https://kloudgov-deployment-artifacts.s3.us-east-1.amazonaws.com/kloudgov-app.zip unzip kloudgov-app.zip ls -l
A couple of intentional choices here worth calling out:
The .gitignore entry comes first, before anything is downloaded. This is a small habit worth building: deciding what shouldn’t be tracked before you have a chance to accidentally stage it, rather than cleaning it up after the fact (the same lesson from securing the Terraform repo in the earlier infrastructure series).
The zip itself never gets committed; only its extracted contents do. Committing a binary archive to Git bloats repository history and defeats the purpose of version control, since Git can’t meaningfully diff a zip file the way it can diff source code.
Step 2: Committing the Extracted Files to the Local Repository
With the source code extracted (and the zip excluded via .gitignore), commit the actual application files:
cd .. git status git add . git status git commit -m "KloudGov app 1st commit" git status
Running git status before and after git add . is a habit worth keeping deliberately; it’s the fastest way to confirm exactly what’s about to be staged and catch anything that shouldn’t be there before it becomes part of your commit history.
Step 3: Connecting to the Instance
Set the correct permissions on the SSH key used to reach the instance, then connect:
ls -l /home/ec2-user/kloudgov-ec2-key.pem chmod 400 /home/ec2-user/kloudgov-ec2-key.pem ls -l /home/ec2-user/kloudgov-ec2-key.pem ssh -i /home/ec2-user/kloudgov-ec2-key.pem ubuntu@<kloudgov-michigan_PRIVATE_IP>
Before running any of the commands below, pause and think about how each one could become an Ansible task. This isn’t a rhetorical exercise; genuinely note which steps feel like they’d map to a module (installing packages, managing files, starting services) versus which ones feel more awkward to automate. That mental list becomes the actual content of Part 3.
Step 4: Updating the Package List
sudo apt update
sudo apt upgrade -y
Nothing remarkable here except that on fifty servers, this is fifty separate manual sessions of watching apt output scroll by, waiting to confirm nothing broke.
Step 5: Installing the Necessary Packages
sudo apt-get install -y nginx python3-pip python3-dev build-essential libssl-dev libffi-dev python3-setuptools python3-venv unzip
This installs Nginx (the reverse proxy), Python tooling, and the build dependencies needed to compile some Python packages from source. unzip earns its place directly here too; it’s what extracts the application archive once it lands on this instance in Step 9.
Step 6: Allowing HTTP Access on the Firewall
sudo ufw allow 'Nginx HTTP'
ufw (Uncomplicated Firewall) is a simpler front-end over iptables. This rule permits inbound traffic on the port Nginx needs separate from, and in addition to, the AWS security group rules already handling network-level access from Part 1.
Step 7: Creating a Project Directory Using Environment Variables
Rather than hardcoding paths and usernames throughout this deployment, set them once as shell variables and reference them everywhere:
# Confirm no "kloudgov" directory already exists pwd ls # Creating and validating variables export project_path=/home/ubuntu/kloudgov echo $project_path env | grep -i project_path export username=ubuntu echo $username env | grep -i username # Creating the project directory mkdir -p $project_path ls -l # Setting ownership and permissions sudo chown $username:$username $project_path sudo chmod 0755 $project_path
Worth pausing on: which Ansible module would replace this step? The file module handles directory creation, ownership, and permissions in a single, idempotent task, meaning it’s safe to run repeatedly without side effects, unlike this sequence of manual shell commands.
Step 8: Creating a Python Virtual Environment
python3 -m venv $project_path/kloudgovenv ls kloudgov/kloudgovenv/
A virtual environment isolates this application’s Python dependencies from the system’s global Python installation standard practice, but one more manual step to remember and get right on every server.
Step 9: Copying the Application Files from EC2 (IDE)
With the target instance prepared, transfer the actual application archive from the EC2 IDE machine where it was downloaded and extracted in Step 1 over to the target instance:
exit # From /home/ec2-user/kloud-gov-application/src pwd /home/ec2-user/kloud-gov-application/src # Copying the application archive to the target instance scp -i /home/ec2-user/kloudgov-ec2-key.pem kloudgov-app.zip ubuntu@<PRIVATE_IP>:/home/ubuntu/kloudgov # Reconnecting and validating the copy ssh -i /home/ec2-user/kloudgov-ec2-key.pem ubuntu@<kloudgov-michigan_PRIVATE_IP> ls kloudgov/
This is a genuinely simpler path than the private-repo route I initially tried: no deploy keys to configure on every target instance, just a direct file copy authenticated with the same SSH key already used to manage the instance. The tradeoff is that this step, scp-ing a specific file to a specific IP, has to be repeated by hand for every single server. That’s a fine amount of friction for one instance. It stops being fine at any real scale, which is exactly the gap Part 3 closes.
Step 10: Unzipping the Application Files
Reconnecting to a fresh SSH session means the shell variables from Step 7 are gone; they don’t persist across sessions:
# Confirm the variables no longer exist env | grep -i project_path env | grep -i username # Set them again export project_path=/home/ubuntu/kloudgov export username=ubuntu export project_name=kloudgov # Confirm env | grep -i project_path env | grep -i username env | grep -i project_name # Extract the application archive unzip $project_path/kloudgov-app.zip -d $project_path ls kloudgov/
Making Variables Permanent (Optional, but Worth Doing)
To avoid resetting these every session, add them to ~/.bashrc:
nano ~/.bashrc
Add at the end of the file:
export project_path=/home/ubuntu/kloudgov
export username=ubuntu
Save (Ctrl+O, Enter, Ctrl+X), then reload:
source ~/.bashrc
This is a small quality-of-life fix, but it’s also a quiet illustration of the core problem with manual deployment: remembering to do this step at all is entirely on you, every time, on every server.
Step 11: Installing Python Dependencies
With the application code now extracted on the target instance, install its dependencies into the virtual environment:
cat $project_path/requirements.txt
$project_path/kloudgovenv/bin/pip install -r $project_path/requirements.txt
Step 12: Creating a systemd Service for Gunicorn
This is the step where the application actually becomes a managed, restartable service rather than a script someone has to remember to run. It creates a systemd unit for Gunicorn, a WSGI server that runs the Python application, so it behaves like any other system service (start on boot, restart on failure, managed via systemctl, exactly like Nginx).
Pay close attention to the environment variables here; they need to match the actual AWS resources for this specific state:
sudo tee /etc/systemd/system/$project_name.service <<EOL [Unit] Description=Gunicorn instance to serve $project_name After=network.target [Service] User=$username Group=www-data WorkingDirectory=$project_path Environment="PATH=$project_path/kloudgovenv/bin" Environment="AWS_REGION=us-east-1" Environment="AWS_DYNAMODB_TABLE=kloudgov-michigan-dynamodb" Environment="AWS_BUCKET=kloudgov-michigan-s3-ku6m" (Paste your actual bucket name here) Environment="US_STATE=michigan" ExecStart=$project_path/kloudgovenv/bin/gunicorn --workers 1 --bind unix:$project_path/$project_name.sock -m 007 $project_name:app [Install] WantedBy=multi-user.target EOL
A few things worth understanding, not just copying:
$project_name.sock doesn’t exist yet; Gunicorn creates it automatically the first time the service starts. It’s a Unix socket file, used for local communication between Gunicorn and Nginx, which is faster and more secure than routing through a network port for purely local traffic.
The AWS_DYNAMODB_TABLE, AWS_BUCKET, and US_STATE values are hardcoded to Michigan specifically. This is the exact kind of value that has to change, correctly, for every single state, and it’s precisely the kind of per-server variation that Ansible variables and templates handle far more reliably than manually editing a heredoc on each machine.
This service definition handles quite a lot in one file: where the project lives, which user runs it, what environment it needs, and how to start it, all manageable afterward through standard systemctl commands.
Before moving on, validate the service file was created correctly:
ls -l /etc/systemd/system/${project_name}.service
cat /etc/systemd/system/${project_name}.service
systemctl status ${project_name}.service
Step 13: Validating User Permissions
ls -l /home/$username echo $username sudo chmod 0755 /home/$username
Step 14: Removing the Default Nginx Configuration
ls /etc/nginx/sites-enabled/default cat /etc/nginx/sites-enabled/default sudo rm /etc/nginx/sites-enabled/default
This removes Nginx’s default “welcome page” configuration to prevent conflicts, effectively taking the example site offline to make room for KloudGov.
Step 15: Creating an Nginx Proxy Configuration
This step creates a new Nginx site configuration that listens on port 80 and forwards incoming requests to the Gunicorn socket, meaning Nginx acts as the intermediary between the public internet and the Python application running behind it:
sudo tee /etc/nginx/sites-available/$project_name <<EOL
server {
listen 80;
server_name $project_name www.$project_name;
location / {
include proxy_params;
proxy_pass http://unix:$project_path/$project_name.sock;
}
}
EOL
Confirm the file landed correctly:
ls /etc/nginx/sites-available/
ls /etc/nginx/sites-enabled/
Step 16: Enabling and Starting the Gunicorn Service
sudo systemctl enable $project_name sudo systemctl start $project_name sudo systemctl status $project_name # Confirm the socket file now exists ls kloudgov/
enable ensures the service starts automatically on system boot.
start launches it immediately.
status confirms it’s actually running, or surfaces the error if it isn’t.
Step 17: Enabling the Nginx Site Configuration
ls /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/$project_name /etc/nginx/sites-enabled/ ls /etc/nginx/sites-enabled/
This symlinks the configuration from sites-available into sites-enabled, which is what actually activates it for Nginx.
Step 18: Restarting Both Services
sudo systemctl restart $project_name sudo systemctl status $project_name sudo systemctl restart nginx sudo systemctl status nginx
Step 19: Testing the Application
Open the EC2 instance’s public DNS in a browser. If everything above went correctly, the KloudGov homepage loads, and the “Add employee” flow works end to end.

Now genuinely sit with this question: imagine doing all of the steps above, correctly, in order, for more than fifty states. Fifty separate SSH sessions. Fifty scp transfers to fifty different IP addresses. Fifty systemd files with slightly different environment variables, typed by hand, with fifty separate chances for a typo in a bucket name or table name to go unnoticed until something breaks in production.
Step 20: Validating Resources on AWS
Confirm the application is actually writing to the right backing resources:

DynamoDB — kloudgov-michigan-dynamodb → Explore items
S3 — kloudgov-michigan-s3-xxxx → Objects

A Known Bug, Left Intentionally
Worth flagging directly: when you delete an employee through the application, the DynamoDB record is removed, but the corresponding file in the S3 bucket is not. This is a deliberate bug, left in place for now; it’ll be addressed later at the application code level, not the infrastructure or deployment level. For the moment, if you need to clean up test data, delete the leftover S3 object manually.

Step 21: Destroying Resources
exit cd ~/kloud-gov-infrastructure/terraform terraform destroy -auto-approve
Close the remote connection and stop the EC2/IDE instance when finished.
What This Was Actually For
Nothing in this post was technically difficult on its own. Downloading a file, installing packages, writing a systemd unit, configuring an Nginx proxy none of it requires advanced expertise. What it does require is doing every single step correctly, in the right order, on every server, with no tooling to catch a typo in an environment variable or a forgotten chmod. That’s the real cost of manual deployment: not complexity, but the accumulation of small, repeatable, error-prone steps multiplied across however many servers you actually need to run.
Worth noting too: my first pass at this used a private Git repo cloned directly onto each instance, and it added real friction per-server deploy key setup that didn’t scale any better than the rest of this process. Switching to a pre-packaged artifact in S3 turned out to be the more practical choice for a manual deployment like this, and arguably closer to how many real teams stage build artifacts in practice.
Key Takeaways
Manual deployment isn’t hard because any individual step is hard; it’s risky because every step depends on a human doing it correctly, in order, every time, on every machine.
Packaging the application as a versioned artifact in S3, rather than cloning a private repo directly onto each target server, avoids per-instance credential setup a real practical win, even in a manual workflow.
Environment-specific values (state name, bucket name, table name) hardcoded into a systemd unit are a direct liability the moment you’re managing more than one server; a single copy-paste mistake silently misconfigures a deployment.
Shell variables set with export don’t persist across sessions a minor annoyance manually, but a preview of a much bigger class of “did I remember to set this up right” problems automation solves permanently.
The real value of this exercise isn’t the working deployment; it’s the mental list of “this step feels like it should be a single, reusable task” that becomes the actual design of the Ansible playbook in Part 3.
Every step in this post has a natural home in an Ansible role: package installation maps to the apt module, directory and permission management to file, artifact retrieval to get_url or unarchive, the systemd unit to template and systemd, the Nginx config to another template task, and service management to service. Part 3 puts all of that together: same deployment, same result, but standardized, repeatable, and a fraction of the manual effort and risk this post just walked through.

