Building and Deploying a Simple Banking Application Using Containerization and Cloud Automation



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/&lt;id&gt;</code></li>
      <li>POST <code>/api/accounts/&lt;id&gt;/deposit</code> – body: <code>{"amount":100}</code></li>
      <li>POST <code>/api/accounts/&lt;id&gt;/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)

  1. 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
  1. SSH into the server

bash
   ssh -i your-key.pem ubuntu@EC2_PUBLIC_IP


  1. 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
    
    
    
  1. 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


  1. 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
    
    
    
  1. (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

  1. Enable Pipelines: Repository → Pipelines → Enable
  2. 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 (or ec2-user on Amazon Linux)

    1. 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:
  1. Install deps + tests
  2. Build Docker image, tag with short commit SHA and latest, push to Docker Hub
  3. 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 correct EC2_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 the docker 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