Manual EC2 Deployment: Configuring Gunicorn and Nginx Without Ansible (Part 2)
Terraform-IAM-Role-For-EC2
ByOlaniyi Oladimeji
Manual EC2 Deployment: Configuring Gunicorn and Nginx Without Ansible (Part 2)

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.

 

{{ reviewsTotal }}{{ options.labels.singularReviewCountLabel }}
{{ reviewsTotal }}{{ options.labels.pluralReviewCountLabel }}
{{ options.labels.noReviewsLabel }}
{{ options.labels.newReviewButton }}
{{ userData.canReview.message }}

Related Posts

EKS-KloudGov-Cluster
AWS EKS Cluster Setup: Provisioning Kubernetes with eksctl and Terraform (Part 1)
This new series adds a third architecture: Kubernetes, via Amazon EKS (Elastic Kubernetes Service), fronted...
Docker-and-ECR
Terraform ECS Automation: Fully Automated, HTTPS-Secured Fargate Services (Part 4)
Everything built by hand in Part 3 the task definition, the target group, the ALB,...
Docker-and-ECR
ECS Task Definitions and Fargate Services: Deploying KloudGov (Part 3)
creating the task definition through the console, launching the service, and watching it come up...