This content originally appeared on DEV Community and was authored by Goodluck Ekeoma Adiole
This project introduces participants to the fundamentals of modern application development and deployment by combining software development, containerization, and DevOps practices.
You will build a simple Banking API that allows basic operations such as creating accounts, deposits, withdrawals, and transfers. While the business logic is simple, the real learning comes from how the application is structured, containerized with Docker, tested with CI, and deployed automatically to an AWS EC2 instance using Bitbucket Pipelines.
Through this project, you will gain hands-on experience with:
Application development (Flask + SQLite)
Containerization (Docker, Gunicorn for production serving)
CI/CD pipelines (Bitbucket Pipelines building, testing, and pushing images)
Cloud deployment (running the container on AWS EC2)
By the end, you’ll understand how modern teams package and deploy applications reliably, a foundational skill for DevOps, Cloud, and Software Engineering careers.
1) Project structure
banking-app/
├─ banking_app/
│ ├─ __init__.py
│ ├─ app.py
│ ├─ models.py
│ ├─ db.py
│ └─ templates/
│ └─ index.html
├─ tests/
│ └─ test_api.py
├─ .env.example
├─ .gitignore
├─ Dockerfile
├─ gunicorn.conf.py
├─ Makefile
├─ README.md
├─ requirements.txt
└─ bitbucket-pipelines.yml
2) File contents (copy exactly)
banking_app/init.py
from flask import Flask
from .db import init_db, db_session, shutdown_session
from .app import api_blueprint
def create_app(config_override=None):
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY="change-me",
DATABASE_URL="sqlite:////app/data/banking.db",
JSON_SORT_KEYS=False
)
if config_override:
app.config.update(config_override)
init_db(app.config["DATABASE_URL"])
# Blueprints
app.register_blueprint(api_blueprint)
# DB session teardown
app.teardown_appcontext(lambda exception: shutdown_session())
# Health endpoint (for LB/containers)
@app.get("/health")
def health():
return {"status": "ok"}, 200
return app
banking_app/db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker, declarative_base
engine = None
db_session = scoped_session(sessionmaker(autocommit=False, autoflush=False))
Base = declarative_base()
def init_db(database_url):
global engine
engine = create_engine(database_url, connect_args={"check_same_thread": False} if database_url.startswith("sqlite") else {})
db_session.configure(bind=engine)
Base.query = db_session.query_property()
from .models import Account, Transaction # noqa
Base.metadata.create_all(bind=engine)
def shutdown_session():
db_session.remove()
banking_app/models.py
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey
from sqlalchemy.orm import relationship
from .db import Base
class Account(Base):
__tablename__ = "accounts"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
balance = Column(Float, nullable=False, default=0.0)
created_at = Column(DateTime, default=datetime.utcnow)
transactions = relationship("Transaction", back_populates="account")
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"balance": round(self.balance, 2),
"created_at": self.created_at.isoformat() + "Z",
}
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True)
type = Column(String(50), nullable=False) # deposit, withdraw, transfer
amount = Column(Float, nullable=False)
note = Column(String(255))
created_at = Column(DateTime, default=datetime.utcnow)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
related_account_id = Column(Integer) # for transfers
account = relationship("Account", back_populates="transactions")
def to_dict(self):
return {
"id": self.id,
"type": self.type,
"amount": round(self.amount, 2),
"note": self.note,
"created_at": self.created_at.isoformat() + "Z",
"account_id": self.account_id,
"related_account_id": self.related_account_id,
}
banking_app/app.py
from flask import Blueprint, jsonify, request, render_template
from sqlalchemy.exc import IntegrityError
from .db import db_session
from .models import Account, Transaction
api_blueprint = Blueprint("api", __name__)
@api_blueprint.route("/", methods=["GET"])
def home():
return render_template("index.html")
@api_blueprint.route("/api/accounts", methods=["POST"])
def create_account():
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
acct = Account(name=name, balance=0.0)
db_session.add(acct)
db_session.commit()
return jsonify({"message": "account created", "account": acct.to_dict()}), 201
@api_blueprint.route("/api/accounts/<int:account_id>", methods=["GET"])
def get_account(account_id):
acct = db_session.get(Account, account_id)
if not acct:
return jsonify({"error": "account not found"}), 404
return jsonify(acct.to_dict()), 200
@api_blueprint.route("/api/accounts/<int:account_id>/deposit", methods=["POST"])
def deposit(account_id):
acct = db_session.get(Account, account_id)
if not acct:
return jsonify({"error": "account not found"}), 404
data = request.get_json(silent=True) or {}
try:
amount = float(data.get("amount"))
except (TypeError, ValueError):
return jsonify({"error": "valid amount is required"}), 400
if amount <= 0:
return jsonify({"error": "amount must be > 0"}), 400
acct.balance += amount
tx = Transaction(type="deposit", amount=amount, note=data.get("note"), account=acct)
db_session.add(tx)
db_session.commit()
return jsonify({"message": "deposit successful", "account": acct.to_dict(), "transaction": tx.to_dict()}), 200
@api_blueprint.route("/api/accounts/<int:account_id>/withdraw", methods=["POST"])
def withdraw(account_id):
acct = db_session.get(Account, account_id)
if not acct:
return jsonify({"error": "account not found"}), 404
data = request.get_json(silent=True) or {}
try:
amount = float(data.get("amount"))
except (TypeError, ValueError):
return jsonify({"error": "valid amount is required"}), 400
if amount <= 0:
return jsonify({"error": "amount must be > 0"}), 400
if acct.balance < amount:
return jsonify({"error": "insufficient funds"}), 400
acct.balance -= amount
tx = Transaction(type="withdraw", amount=amount, note=data.get("note"), account=acct)
db_session.add(tx)
db_session.commit()
return jsonify({"message": "withdrawal successful", "account": acct.to_dict(), "transaction": tx.to_dict()}), 200
@api_blueprint.route("/api/transfer", methods=["POST"])
def transfer():
data = request.get_json(silent=True) or {}
try:
from_id = int(data.get("from_id"))
to_id = int(data.get("to_id"))
amount = float(data.get("amount"))
except (TypeError, ValueError):
return jsonify({"error": "from_id, to_id and valid amount are required"}), 400
if amount <= 0:
return jsonify({"error": "amount must be > 0"}), 400
if from_id == to_id:
return jsonify({"error": "cannot transfer to the same account"}), 400
from_acct = db_session.get(Account, from_id)
to_acct = db_session.get(Account, to_id)
if not from_acct or not to_acct:
return jsonify({"error": "one or both accounts not found"}), 404
if from_acct.balance < amount:
return jsonify({"error": "insufficient funds"}), 400
# Perform transfer atomically
try:
from_acct.balance -= amount
to_acct.balance += amount
tx_out = Transaction(type="transfer", amount=-amount, note=f"to {to_acct.id}", account=from_acct, related_account_id=to_acct.id)
tx_in = Transaction(type="transfer", amount=amount, note=f"from {from_acct.id}", account=to_acct, related_account_id=from_acct.id)
db_session.add_all([tx_out, tx_in])
db_session.commit()
except IntegrityError:
db_session.rollback()
return jsonify({"error": "transfer failed"}), 500
return jsonify({
"message": "transfer successful",
"from": from_acct.to_dict(),
"to": to_acct.to_dict()
}), 200
banking_app/templates/index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Banking API</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; }
code { background: #f5f5f5; padding: 0.15rem 0.3rem; border-radius: 6px; }
pre { background: #f5f5f5; padding: 1rem; border-radius: 8px; overflow: auto; }
a { color: #0a58ca; text-decoration: none; }
</style>
</head>
<body>
<h1>Banking API</h1>
<p>This is a beginner-level banking API you can deploy with Docker and Bitbucket Pipelines.</p>
<h2>Quick endpoints</h2>
<ul>
<li>GET <code>/health</code></li>
<li>POST <code>/api/accounts</code> – body: <code>{"name":"Alice"}</code></li>
<li>GET <code>/api/accounts/<id></code></li>
<li>POST <code>/api/accounts/<id>/deposit</code> – body: <code>{"amount":100}</code></li>
<li>POST <code>/api/accounts/<id>/withdraw</code> – body: <code>{"amount":50}</code></li>
<li>POST <code>/api/transfer</code> – body: <code>{"from_id":1,"to_id":2,"amount":25}</code></li>
</ul>
</body>
</html>
tests/test_api.py
import json
import os
import tempfile
import pytest
from banking_app import create_app
from banking_app.db import init_db
@pytest.fixture
def client():
db_fd, db_path = tempfile.mkstemp()
os.close(db_fd)
app = create_app({
"TESTING": True,
"SECRET_KEY": "test",
"DATABASE_URL": f"sqlite:///{db_path}"
})
with app.test_client() as client:
yield client
os.remove(db_path)
def test_health(client):
rv = client.get("/health")
assert rv.status_code == 200
assert rv.get_json()["status"] == "ok"
def test_create_and_get_account(client):
rv = client.post("/api/accounts", json={"name": "Alice"})
assert rv.status_code == 201
acct_id = rv.get_json()["account"]["id"]
rv2 = client.get(f"/api/accounts/{acct_id}")
assert rv2.status_code == 200
assert rv2.get_json()["name"] == "Alice"
def test_deposit_withdraw_and_transfer(client):
a = client.post("/api/accounts", json={"name": "A"}).get_json()["account"]["id"]
b = client.post("/api/accounts", json={"name": "B"}).get_json()["account"]["id"]
# Deposit to A
rv = client.post(f"/api/accounts/{a}/deposit", json={"amount": 100})
assert rv.status_code == 200
assert rv.get_json()["account"]["balance"] == 100
# Withdraw from A
rv = client.post(f"/api/accounts/{a}/withdraw", json={"amount": 40})
assert rv.status_code == 200
assert rv.get_json()["account"]["balance"] == 60
# Transfer 25 from A to B
rv = client.post("/api/transfer", json={"from_id": a, "to_id": b, "amount": 25})
assert rv.status_code == 200
from_balance = rv.get_json()["from"]["balance"]
to_balance = rv.get_json()["to"]["balance"]
assert from_balance == 35
assert to_balance == 25
.env.example
# Copy this to .env (for local dev) or /srv/banking-app/.env (on EC2)
FLASK_SECRET_KEY=change-this-in-production
DATABASE_URL=sqlite:////app/data/banking.db
LOG_LEVEL=INFO
.gitignore
__pycache__/
*.pyc
.env
.env.*
*.db
dist/
build/
*.egg-info
.pytest_cache/
data/
Dockerfile
# Simple, production-ready container
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
# System deps
RUN apt-get update && apt-get install -y --no-install-recommends \
tini curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
# App directory
WORKDIR /app
# Install deps first (better layer caching)
COPY requirements.txt .
RUN pip install -r requirements.txt
# Create a non-root user
RUN useradd -m appuser
RUN mkdir -p /app/data && chown -R appuser:appuser /app
# Copy app
COPY banking_app ./banking_app
COPY gunicorn.conf.py .
COPY .env.example ./.env.example
USER appuser
EXPOSE 8000
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["gunicorn", "-c", "gunicorn.conf.py", "banking_app:create_app()"]
gunicorn.conf.py
bind = "0.0.0.0:8000"
workers = 2
threads = 2
timeout = 30
graceful_timeout = 30
keepalive = 5
accesslog = "-"
errorlog = "-"
Makefile
IMAGE ?= your-dockerhub-username/banking-app
TAG ?= local
.PHONY: run test build docker-run docker-push
run:
\tuvicorn
test:
\tpytest -q
build:
\tdocker build -t $(IMAGE):$(TAG) .
docker-run:
\tmkdir -p data
\tdocker run --rm -it -p 8000:8000 -v ${PWD}/data:/app/data --env-file .env $(IMAGE):$(TAG)
docker-push:
\tdocker push $(IMAGE):$(TAG)
requirements.txt
Flask==3.0.3
SQLAlchemy==2.0.31
gunicorn==22.0.0
pytest==8.2.2
bitbucket-pipelines.yml
image: atlassian/default-image:3
options:
docker: true
pipelines:
branches:
main:
- step:
name: Build & Test
caches:
- docker
services:
- docker
script:
- echo "Running unit tests"
- pip install -r requirements.txt
- pytest -q
- step:
name: Build Docker image
services:
- docker
script:
- export DOCKER_IMAGE="${DOCKERHUB_USERNAME}/banking-app"
- export TAG="${BITBUCKET_COMMIT:0:7}"
- echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- docker build -t "$DOCKER_IMAGE:$TAG" -t "$DOCKER_IMAGE:latest" .
- docker push "$DOCKER_IMAGE:$TAG"
- docker push "$DOCKER_IMAGE:latest"
artifacts:
- deployment/**
- step:
name: Deploy to EC2
deployment: production
script:
- pipe: atlassian/ssh-run:0.7.0
variables:
SSH_USER: $EC2_USER
SERVER: $EC2_HOST
PORT: "22"
MODE: "command"
COMMAND: >
set -e;
sudo mkdir -p /srv/banking-app/data;
sudo chown -R $EC2_USER:$EC2_USER /srv/banking-app;
echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin;
DOCKER_IMAGE="${DOCKERHUB_USERNAME}/banking-app";
TAG="${BITBUCKET_COMMIT:0:7}";
docker pull "$DOCKER_IMAGE:$TAG";
(docker rm -f banking-app || true);
docker run -d --name banking-app --restart always
-p 80:8000
--env-file /srv/banking-app/.env
-v /srv/banking-app/data:/app/data
"$DOCKER_IMAGE:$TAG";
definitions:
services:
docker:
memory: 3072
Bitbucket Repository Variables you must add (Settings → Repository variables):
DOCKERHUB_USERNAME
DOCKERHUB_PASSWORD
(use a Docker Hub access token)EC2_HOST
(e.g.,3.XX.XX.XX
)EC2_USER
(e.g.,ubuntu
for Ubuntu,ec2-user
for Amazon Linux)SSH access: Add your Bitbucket Pipelines public key to the EC2 instance’s
~/.ssh/authorized_keys
. In Bitbucket: Repository Settings → SSH keys → Generate keys, then copy the public key to the server.
3) README.md (instructions for completing the project)
Create this README.md in the repo root:
# Banking API — Docker + Bitbucket Pipelines → AWS EC2 (Beginner Friendly)
A tiny Flask + SQLite banking API you can build in Docker and deploy automatically to an AWS EC2 instance using Bitbucket Pipelines.
## Features
- Create accounts, deposit, withdraw, transfer
- SQLite database (file persisted on the server)
- Gunicorn production server
- Healthcheck `/health`
- Unit tests with pytest
- Bitbucket Pipelines: build → push Docker image → SSH deploy to EC2
- Zero-downtime style (container is replaced quickly)
---
## 1) Prerequisites
- Docker & Docker Compose (for local)
- A Docker Hub account (for pushing images)
- Bitbucket repository
- An EC2 instance (Ubuntu 22.04 LTS or Amazon Linux 2023 recommended)
- Security Group allows inbound TCP 80 (HTTP) from your IP/Internet
---
## 2) Run locally (optional, recommended)
```
bash
# Clone and enter
git clone <your-repo-url> banking-app
cd banking-app
# Create a local .env from example (you can keep defaults)
cp .env.example .env
# Build and run
docker build -t your-dockerhub-username/banking-app:local .
docker run --rm -it -p 8000:8000 \
-v ${PWD}/data:/app/data \
--env-file .env \
your-dockerhub-username/banking-app:local
Test in another terminal:
bash
curl -s http://localhost:8000/health
curl -s -X POST http://localhost:8000/api/accounts -H "Content-Type: application/json" -d '{"name":"Alice"}'
3) Run tests locally
bash
pip install -r requirements.txt
pytest -q
4) Prepare AWS EC2 (one-time)
- Launch EC2
- AMI: Ubuntu 22.04 LTS (or Amazon Linux 2023)
- Instance type: t3.micro is fine for demo
- Security Group: allow inbound HTTP (80) and SSH (22) from your IP
- SSH into the server
bash
ssh -i your-key.pem ubuntu@EC2_PUBLIC_IP
- Install Docker
-
Ubuntu:
bash sudo apt-get update sudo apt-get install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io sudo usermod -aG docker $USER newgrp docker
- Create app folder and .env
bash
sudo mkdir -p /srv/banking-app/data
sudo chown -R $USER:$USER /srv/banking-app
cat << 'EOF' > /srv/banking-app/.env
FLASK_SECRET_KEY=change-this-in-production
DATABASE_URL=sqlite:////app/data/banking.db
LOG_LEVEL=INFO
EOF
- Add Bitbucket Pipelines SSH key to server
- In Bitbucket: Repository Settings → SSH keys → Generate key
-
Copy the public key shown there and append to the server:
bash mkdir -p ~/.ssh chmod 700 ~/.ssh echo "<PASTE_PUBLIC_KEY_HERE>" >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys
- (Optional) Open firewall for HTTP
-
If using Ubuntu UFW:
bash sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw enable sudo ufw status
5) Configure Bitbucket Pipelines
- Enable Pipelines: Repository → Pipelines → Enable
- Repository Variables (Settings → Repository variables):
-
DOCKERHUB_USERNAME
=<your-dockerhub-username>
-
DOCKERHUB_PASSWORD
=<docker-hub-access-token>
(create from Docker Hub → Security) -
EC2_HOST
=ec2-x-x-x-x.compute-1.amazonaws.com
or the public IP -
EC2_USER
=ubuntu
(orec2-user
on Amazon Linux)- SSH key: Repository Settings → SSH keys → Generate keys
This is the key Pipelines uses to SSH into your EC2.
Add the public key to your EC2 server as described above.
6) Deploy flow
- Push to branch
main
. - Pipeline runs:
- Install deps + tests
- Build Docker image, tag with short commit SHA and
latest
, push to Docker Hub - SSH into EC2:
* Login to Docker Hub
* Pull the image
* Stop and remove the running container (if any)
* Run a new container on port 80 → 8000
Verify:
Visit http://<EC2_PUBLIC_IP>/health
→ should return {"status":"ok"}
.
7) Example API usage
bash
# Create two accounts
curl -s -X POST http://EC2_PUBLIC_IP/api/accounts -H "Content-Type: application/json" -d '{"name":"Alice"}'
curl -s -X POST http://EC2_PUBLIC_IP/api/accounts -H "Content-Type: application/json" -d '{"name":"Bob"}'
# Deposit to Alice (id 1)
curl -s -X POST http://EC2_PUBLIC_IP/api/accounts/1/deposit -H "Content-Type: application/json" -d '{"amount": 100}'
# Transfer from Alice (1) to Bob (2)
curl -s -X POST http://EC2_PUBLIC_IP/api/transfer -H "Content-Type: application/json" -d '{"from_id":1,"to_id":2,"amount":25}'
8) Common troubleshooting
- 403/timeout reaching EC2: Check Security Group allows inbound TCP 80 from your IP/Internet.
-
Pipeline can’t SSH: Ensure Bitbucket Pipelines public key is in
~/.ssh/authorized_keys
on EC2 and you used the correctEC2_USER
. -
Container fails to start:
docker logs banking-app
on EC2 to see errors. -
DB not persisting: Ensure volume
-v /srv/banking-app/data:/app/data
is present in thedocker run
command (the pipeline does this). -
Change port: Edit
-p 80:8000
in the deploy step, and adjust security group accordingly.
9) Cleaning up / re-deploy manually
bash
# On EC2:
docker ps
docker stop banking-app && docker rm banking-app
docker pull your-dockerhub-username/banking-app:latest
docker run -d --name banking-app --restart always -p 80:8000 \
--env-file /srv/banking-app/.env \
-v /srv/banking-app/data:/app/data \
your-dockerhub-username/banking-app:latest
10) Notes on security (basics)
- Always use a Docker Hub access token (not your password) for
DOCKERHUB_PASSWORD
. - Keep
FLASK_SECRET_KEY
strong and private in/srv/banking-app/.env
(don’t commit it). - Consider attaching an Elastic IP to the instance and setting up a domain + HTTPS with a reverse proxy later.
This content originally appeared on DEV Community and was authored by Goodluck Ekeoma Adiole