AWS Builder Center
Connect Your App to a Cloud Database

Connect Your App to a Cloud Database

Learn how to connect a sample Python application to a RDS instance in AWS.

Developer Relations Engineer
You have an API. It works. The data is fake. Let's fix that with Amazon RDS, a Flask app, and zero prior cloud experience.

You write some routes, hardcode some data, hit the endpoint in your terminal, and feel like a genius for about fifteen minutes. Then someone asks, "Where does the data live?" and you gesture vaguely at your laptop like it's a server rack you bought at a garage sale.
Hardcoded data is a lie your app tells with a straight face. It responds to requests. It returns JSON with proper status codes. But nothing gets stored or persisted anywhere. Close the app, reopen it, same fake data, same fake confidence. A movie set: looks like a house from the front, plywood and prayer from the back.
In this post, you'll clone a Flask API that returns hardcoded data, spin up a MySQL database on Amazon RDS (a managed database in the cloud), wire the two together, and watch fake data become persistent. Same curl commands, same routes, different reality underneath.
Your app code changes by about one file. You swap a Python dictionary for a database connection, and the rest stays identical.

What You'll Build

By the end of this post, you'll have:
  • A Flask API with full CRUD routes (Create, Read, Update, Delete)
  • That API connected to a MySQL database running on Amazon RDS
  • Data that persists across server restarts, laptop closings, and coffee spills
Three acts:
  1. Act 1: Clone the Flask API and watch hardcoded data betray you
  2. Act 2: Set up a MySQL database on Amazon RDS
  3. Act 3: Connect the API to RDS and prove persistence works

What You'll Need

No prior AWS or database experience needed. If you can copy-paste a curl command, you're qualified.

Act 1: The Fake Version (It Works, but It's Living a Lie)

We built a Flask API that handles users: creating, reading, updating, deleting. Full CRUD. Except none of it is real. The data lives in a Python dictionary that evaporates the moment you stop the server. Writing your grocery list on a fogged-up mirror.
Clone it and see the problem yourself.

Step 1: Clone the Repo and Set Up

1
2
git clone https://github.com/sdahal1/flask-api.git
cd flask-api
Create a virtual environment and install dependencies:
1
2
3
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

Step 2: Run the App

1
python app.py
You should see:
1
* Running on http://127.0.0.1:5000
Open a new terminal tab (keep the server running in the first one) and fire off some curl commands.

Step 3: Test the CRUD Operations

Read all users:
1
curl http://localhost:5000/users
1
2
3
4
5
[
{"id": 1, "username": "mateo_jackson", "email": "mateo@example.com"},
{"id": 2, "username": "mary_major", "email": "mary@example.com"},
{"id": 3, "username": "paulo_santos", "email": "paulo@example.com"}
]
Three users. They were there before you asked, because they're hardcoded. The API didn't retrieve them from anywhere. They sat in a dictionary, waiting to be acknowledged. Like coworkers in a meeting who already had their cameras on.
Read one user:
1
curl http://localhost:5000/users/1
1
{"id": 1, "username": "mateo_jackson", "email": "mateo@example.com"}
Create a new user:
1
2
3
curl -X POST http://localhost:5000/users \
-H "Content-Type: application/json" \
-d '{"username": "nikki_wolf", "email": "nikki@example.com"}'
1
{"id": 4, "username": "nikki_wolf", "email": "nikki@example.com"}
ID 4. The API accepted it, stored it in the dictionary, returned it. Feels real.
Update a user:
1
curl -X PUT http://localhost:5000/users/1 -H "Content-Type: application/json" -d '{"username": "mateo_updated"}'
1
{"id": 1, "username": "mateo_updated", "email": "mateo@example.com"}
Delete a user:
1
curl -X DELETE http://localhost:5000/users/3
1
{"message": "User 3 deleted"}
Proper status codes, proper JSON. Looks like a real backend.

The Problem: Stop the Server, Lose Everything

Stop the server (Ctrl+C) and start it again:
1
python app.py
Read again:
1
curl http://localhost:5000/users
1
2
3
4
5
[
{"id": 1, "username": "mateo_jackson", "email": "mateo@example.com"},
{"id": 2, "username": "mary_major", "email": "mary@example.com"},
{"id": 3, "username": "paulo_santos", "email": "paulo@example.com"}
]
Nikki? Gone. Mateo's username update? Reverted. Paulo's deletion? Undone. The dictionary reset to its hardcoded state. Groundhog Day for data.
Hardcoded data has no persistence. No database catches your changes. Restarts are factory resets. Deploys are amnesia.
You need a real database, one that doesn't live and die with your Python process, running somewhere more reliable than your laptop, which is one coffee spill away from becoming a very expensive paperweight.

Act 2: Set Up a MySQL Database on Amazon RDS

Before you go straight into Amazon RDS as the database service. It is important to note that developers generally use the local MySQL database on their own machine during development. However, since this post is about connecting an existing app to Amazon RDS, we will dive right into AWS console in the upcoming steps. If you are new to MySQL, you can find the full walkthrough of setting up and using MySQL database locally in this blog: SQL and Relational Databases Beginner guide 
Amazon RDS (Relational Database Service) runs a managed MySQL database in the cloud. "Managed" means AWS handles backups, security patches, hardware failures, and scaling. You write the SQL. They keep the server running. Like hiring a building superintendent for your data.

Step 1: Create the Database

Sign in to the AWS Management Console  and search for "RDS" in the top search bar. Choose Aurora and RDS, then choose Create under Create with full configuration.
Welcome to Aurora and RDS Page
The form has a lot of options. Most stay at the default.
Create Database Page
Engine options: - Engine type: MySQL
Database creation method: - Select Full configuration
Templates: - Select Free tier, which locks the instance to a small size covered by the AWS Free Tier
Availability and durability, Deployment options: Leave the default (Single-AZ instance deployment)
Database Settings Configuration Options
Under Settings, leave the Edition and Engine version defaults. Then:
DB instance identifier: my-app-db
Master username: admin
Credentials management: Self managed
Master password: pick something strong and save it somewhere. You'll need it in a few minutes.
Additional credentials settings: Leave the default (Password authentication)
Instance configuration: - Leave the default (db.t3.micro or db.t4g.micro). Free tier picks this for you.
Storage: - Leave all defaults (20 GB, General Purpose SSD). Plenty for this project.
Connectivity, the section that trips up almost everyone:
Most connectivity settings stay as default. One setting, if you get it wrong, will cost you an hour staring at a connection timeout error with zero explanation.
Database Connectivity Options
  • Compute resource: Don't connect to an EC2 compute resource
  • Virtual private cloud (VPC): Default VPC
  • DB subnet group: default
  • Public access: Yes ← By default this is No. Leave it on No and your laptop can't reach the database. You'll get a timeout. Change it to Yes.
In production, you'd keep Public access set to No and connect through a private network. For learning, Yes is correct.
  • VPC security group: Create new
  • New VPC security group name: my-app-db-sg
  • Everything else: leave as default
Everything else (Tags, Monitoring, Additional configuration): leave all defaults. Don't expand anything.
Scroll to the bottom and click Create database.
Takes 3-5 minutes. AWS is provisioning a server, installing MySQL, configuring backups, setting up encryption. Good time to refill your coffee.

Step 2: Configure the Security Group

Your database exists but isn't accepting connections. The security group (a firewall) needs a rule that allows your laptop in.
  1. In the RDS console, click your database instance (my-app-db)
  2. On the Connectivity & security tab, scroll down to Security group rules
Security Group Rules Configuration
  1. Click the inbound security group link (blue text, something like my-app-db-sg, type CIDR/IP - Inbound)
  2. Click the Security group ID
  3. Under the Inbound rules tab, click Edit inbound rules
  4. Click Add rule: - Type: MySQL/Aurora (auto-fills port 3306) - Source: select My IP (AWS detects your current IP address)
  5. Click Save rules
This restricts MySQL connections to your specific IP address. Switch wifi networks later and you'll need to update this rule with your new IP.

Step 3: Get Your Connection Endpoint

Back in the RDS console, click your database instance. Under Connectivity & security, find the Connect using section. Choose Endpoints. Leave Endpoint type as the default Instance endpoint. Under Additional configurations, Connectivity & security, you’ll see an endpoint that looks like this:
1
my-app-db.abc123xyz.us-east-1.rds.amazonaws.com
Obtaining Connectivity Endpoint
Copy that endpoint. Your app uses this instead of localhost. The port is 3306.

Step 4: Connect with MySQL Workbench and Create Your Database

Open MySQL Workbench and create a new connection:
  • Click the + icon next to "MySQL Connections"
  • Connection Name: RDS - my-app-db
  • Hostname: paste the endpoint you copied
  • Port: 3306
  • Username: admin
  • Click Test Connection, enter your master password
  • Success message? Click OK, save, and double-click the connection to open it
You're connected to a cloud database. Same Workbench interface as a local one.
Create the database and table by entering the following SQL and choosing Run (the lightning icon):
1
CREATE DATABASE flask_app;
1
USE flask_app;
1
2
3
4
5
6
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Don't insert any data. The API will do that in Act 3. Confirm the table is empty:
1
SELECT * FROM users;
Zero rows. Clean slate.

Act 3: Connect the API to RDS (The Moment of Truth)

You're ripping out the hardcoded dictionary and pointing the Flask app at a real database. The routes stay the same. The curl commands stay the same. The data source changes, and this time, MySQL will remember what you tell it.

Step 1: Install the Database Driver

In your terminal (virtual environment activated), install pymysql, the Python library for talking to MySQL:
1
pip install pymysql

Step 2: Rewrite the App

Replace the contents of app.py with this:
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import os
import pymysql
from flask import Flask, jsonify, request

app = Flask(__name__)

# Database connection settings from environment variables
# so credentials don't sit in source code like a password
# on a sticky note attached to your monitor
DB_HOST = os.environ.get("DB_HOST", "localhost")
DB_USER = os.environ.get("DB_USER", "admin")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "")
DB_NAME = os.environ.get("DB_NAME", "my-app-db")
DB_PORT = int(os.environ.get("DB_PORT", 3306))

def get_db():
"""Get a database connection."""
return pymysql.connect(
host=DB_HOST,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME,
port=DB_PORT,
cursorclass=pymysql.cursors.DictCursor,
)

# READ all users
@app.route("/users", methods=["GET"])
def get_users():
conn = get_db()
try:
with conn.cursor() as cursor:
cursor.execute("SELECT id, username, email FROM users")
users = cursor.fetchall()
return jsonify(users)
finally:
conn.close()

# READ one user
@app.route("/users/<int:user_id>", methods=["GET"])
def get_user(user_id):
conn = get_db()
try:
with conn.cursor() as cursor:
cursor.execute(
"SELECT id, username, email FROM users WHERE id = %s",
(user_id,),
)
user = cursor.fetchone()
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user)
finally:
conn.close()

# CREATE a user
@app.route("/users", methods=["POST"])
def create_user():
data = request.get_json()
if not data or not data.get("username") or not data.get("email"):
return jsonify({"error": "username and email are required"}), 400
conn = get_db()
try:
with conn.cursor() as cursor:
cursor.execute(
"INSERT INTO users (username, email) VALUES (%s, %s)",
(data["username"], data["email"]),
)
conn.commit()
new_id = cursor.lastrowid
return jsonify({"id": new_id, "username": data["username"],
"email": data["email"]}), 201
finally:
conn.close()

# UPDATE a user
@app.route("/users/<int:user_id>", methods=["PUT"])
def update_user(user_id):
data = request.get_json()
conn = get_db()
try:
with conn.cursor() as cursor:
cursor.execute(
"SELECT id, username, email FROM users WHERE id = %s",
(user_id,),
)
user = cursor.fetchone()
if not user:
return jsonify({"error": "User not found"}), 404
new_username = data.get("username", user["username"])
new_email = data.get("email", user["email"])
cursor.execute(
"UPDATE users SET username = %s, email = %s WHERE id = %s",
(new_username, new_email, user_id),
)
conn.commit()
return jsonify({"id": user_id, "username": new_username,
"email": new_email})
finally:
conn.close()

# DELETE a user
@app.route("/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
conn = get_db()
try:
with conn.cursor() as cursor:
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
conn.commit()
if cursor.rowcount == 0:
return jsonify({"error": "User not found"}), 404
return jsonify({"message": f"User {user_id} deleted"})
finally:
conn.close()

if __name__ == "__main__":
app.run(debug=True)
The hardcoded users dictionary is gone. Each route opens a connection to MySQL, runs a query, and returns the result. get_db() reads credentials from environment variables so they stay out of your source code.
A few things worth noting in the new code:
  • %s placeholders prevent SQL injection. You never paste user input into SQL strings. PyMySQL escapes the values for you.
  • conn.commit() tells the database "yes, I meant that." Without it, your INSERT, UPDATE, and DELETE changes sit in limbo, like a draft email you never sent.
  • conn.close() releases the connection. Leaving connections open is like leaving your front door unlocked and wondering why the heating bill is high.
  • DictCursor returns rows as dictionaries ({"id": 1, "username": "mateo"}) instead of tuples ((1, "mateo")), which makes building JSON responses easier.

Step 3: Set Your Environment Variables

Tell the app where the database lives. Replace the endpoint and password with your values by pasting these into the terminal tab where you’re running the app (quit the server first by hitting Ctrl-C):
1
2
3
4
5
export DB_HOST='REPLACE_WITH_YOUR_HOST_DB_ENDPOINT_HERE'
export DB_USER='admin'
export DB_PASSWORD='REPLACE_WITH_YOUR_PASSWORD_HERE'
export DB_NAME='my-app-db'
export DB_PORT='3306'
Note: Ensure that you're running these export commands in the same terminal instance where you will run the next command. The environment variables only persist per terminal.

Step 4: Run It

1
python app.py
Same server, same port, same routes. A real database behind them now.

Step 5: Test It, Starting From an Empty Database

In Act 1, the app came pre-loaded with three users. This time the database is empty. You build the data yourself through the API.
Read all users (empty database):
1
curl http://localhost:5000/users
1
[]
localhost is still our Flask application endpoint, but it is querying the RDS database now. If you get an error that says “pymysql.err.OperationalError: (2003, "Can't connect to MySQL server on 'my-app-db.abc123xyz.us-east-1.rds.amazonaws.com' ([Errno 8] nodename nor servname provided, or not known)")" — be sure you've replaced the host and password with your own!
Empty list. No hardcoded users. No fake data. An honest, empty table.
Create some users:
1
curl -X POST http://localhost:5000/users -H "Content-Type: application/json" -d '{"username": "mateo_jackson", "email": "mateo@example.com"}'
1
{"id": 1, "username": "mateo_jackson", "email": "mateo@example.com"}
1
curl -X POST http://localhost:5000/users -H "Content-Type: application/json" -d '{"username": "mary_major", "email": "mary@example.com"}'
1
{"id": 2, "username": "mary_major", "email": "mary@example.com"}
1
curl -X POST http://localhost:5000/users -H "Content-Type: application/json" -d '{"username": "paulo_santos", "email": "paulo@example.com"}'
1
{"id": 3, "username": "paulo_santos", "email": "paulo@example.com"}
Three users, each inserted into MySQL on RDS with a real INSERT statement. The database assigned the IDs.
Read all users to verify:
1
curl http://localhost:5000/users
1
2
3
4
5
[
{"id": 1, "username": "mateo_jackson", "email": "mateo@example.com"},
{"id": 2, "username": "mary_major", "email": "mary@example.com"},
{"id": 3, "username": "paulo_santos", "email": "paulo@example.com"}
]
Three rows from a real database on a server in an AWS data center.
Read one user:
1
curl http://localhost:5000/users/1
1
{"id": 1, "username": "mateo_jackson", "email": "mateo@example.com"}
Update a user:
1
curl -X PUT http://localhost:5000/users/1 -H "Content-Type: application/json" -d '{"username": "mateo_updated"}'
1
{"id": 1, "username": "mateo_updated", "email": "mateo@example.com"}
Delete a user:
1
curl -X DELETE http://localhost:5000/users/3
1
{"message": "User 3 deleted"}
Full CRUD against a real database. Now for the test that broke Act 1.

The Restart Test

Stop the server (Ctrl+C). Start it again:
1
python app.py
Read all users:
1
curl http://localhost:5000/users
1
2
3
4
[
{"id": 1, "username": "mateo_updated", "email": "mateo@example.com"},
{"id": 2, "username": "mary_major", "email": "mary@example.com"}
]
Mateo's username is still mateo_updated. Paulo is still deleted. The server restarted. The data didn't reset. MySQL kept it because the database runs independently of your Python process, your laptop, and your wifi connection.
A dictionary forgets on restart. A database doesn't.

Behind the Scenes

Clicking "Create database" in the RDS console triggered AWS to provision a MySQL server on a small virtual machine, install MySQL, configure memory, partition disks, and enable automated backups. You didn't touch a config file.
Your database now has:
  • Automated daily backups with point-in-time recovery. Drop a table at 2 PM, restore to 1:59 PM. RDS enables this by default.
  • Encryption in transit. Traffic between your Flask app and the AWS data center is encrypted. Nobody reads your SQL queries over the wire.
  • A security group acting as a firewall, restricting connections to the IP addresses and ports you specified.
The environment variable change in Act 3 is the only thing your app noticed. Backups, encryption, networking, all of that runs at the infrastructure layer. Your Python code and SQL queries run the same way they did with the hardcoded version. The database moved to a different address, and AWS took over maintenance.
Additionally, creating an RDS database completes the “Create an Aurora or RDS database” activity for new accounts. You should see your additional $20 in credits in your account!
Obtain AWS Credits

Cost

RDS is eligible under the Free plan. You can use your Free Tier credits toward RDS usage with db.t3.micro and db.t4g.micro instances, and 4 engines: MySQL, PostgreSQL, MariaDB, and Microsoft SQL Server.
To give you an idea of how much Free Tier credits this will consume: a db.t4g.micro instance like the one we used in this tutorial costs $0.016 per hour if running in US East (N. Virginia). This costs about 38 cents per day, and $2.69 per week. If you left this database running for the month of March, this would cost about $83.33. Under the Free plan, this charge will come out of your Free Tier credits.
Remember that your credit card will not be charged under the Free plan. If you run out of credits before the 6-month Free plan ends, your account will be closed. For more information, check out the Free Tier FAQs .
Don’t forget: you can always check your usage on the Cost and usage dashboard on your account home page .
Cost and usage dashboard

Clean Up Your Resources

Done experimenting? Clean up so you don't burn through your credits.
Option 1: Stop the instance (coming back later)
In the RDS console, select your database instance, click Actions → Stop temporarily. This pauses the instance for up to 7 days. RDS auto-restarts it after 7 days for maintenance, so set a reminder if you're stepping away longer.
Stopped instances don't consume compute hours. The Free Tier includes 20 GB of storage, so a single stopped instance costs nothing.
Option 2: Delete the instance (done for good)
  1. In the RDS console, select your database instance
  2. Click Actions → Delete
  3. Uncheck Create final snapshot
  4. Uncheck Retain automated backups
  5. Type delete me in the confirmation field and click Delete
The instance and all its data get permanently removed.
The security group sticks around after you delete the database. It costs nothing, but if you want a clean account: VPC console → Security groups, find my-app-db-sg, delete it.
Clear your environment variables so the app doesn't try connecting to a dead database:
1
unset DB_HOST DB_USER DB_PASSWORD DB_NAME DB_PORT

Recap

You started with a Flask API returning hardcoded data from a Python dictionary, where restarting the server wiped out every change you made. You created a MySQL database on Amazon RDS, rewired the app to use it, and proved that data now survives restarts. The routes, curl commands, and JSON responses stayed identical. The storage layer changed from volatile memory to a managed cloud database with automated backups and encryption.
The companion post on relational databases covers the SQL running under the hood, table relationships, and JOINs if you want to go deeper.

What's Next:

You've gotten all the way towards the end of the New to AWS series. Your next step in this series will be to read our final post in the series: What's next on AWS Free Tier  where you will learn some other details about how the free tier plan works and ideas for what to build next with your new foundational knowledge.

Everything here runs within the AWS Free Tier. No surprise bills.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article