├── .gitignore ├── README.md ├── app ├── config.py ├── crud.py ├── db.py ├── ip.py ├── main.py ├── models.py ├── prod_calls.py ├── pytest.ini ├── requirements.txt ├── schema.py ├── test_schema.py └── test_views.py ├── html └── index.nginx-debian.html ├── inventory.ini ├── nginx ├── my_lb.conf └── myapp.conf ├── postgresql-server └── reference.md └── supervisor └── myapp.supervisor.conf /.gitignore: -------------------------------------------------------------------------------- 1 | app/.env 2 | .env 3 | delete-me.txt 4 | 5 | bin/ 6 | lib/ 7 | include/ 8 | pyvenv.cfg 9 | 10 | __pycache__/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://event.on24.com/wcc/r/3349436/AC9A63C2985E4B8622CFA5FFCAFDB1BD) 2 | 3 | # Deploy FastAPI from Scratch to a production server with this series. 4 | 5 | As you go down the rabbit hole of building Python Web Applications you inevitably start to wonder how this is all going to run in a live, production, environment. This series aims to answer this question by deploying a basic Python Web Application from scratch to Linode's powerful and cost-effective service. Once you understand the fundamentals from scratch, you can learn to deploy using more scalable and powerful solutions (like Terraform, Ansible, and others). 6 | 7 | #### Chapter 1: Setup & Recommendations 8 | - Walkthrough- What we are doing and why 9 | - Recommendations before we get started 10 | - Provisions on Linode 11 | - Your first secure connection 12 | 13 | #### Chapter 2: Users, Permissions, Firewalls & Nginx 14 | - Passwordless SSH with SSH Public Keys 15 | - Configure new Users & Group Permissions 16 | - Install nginx and UFW 17 | 18 | 19 | #### Chapter 3: Version Control & Git 20 | - Why we need version control 21 | - Git Basics 22 | - Git Remote Host on Linode Part 1 23 | - Git Remote Host on Linode Part 2 24 | 25 | #### Chapter 4: Web Application 26 | - Install Python & use a virtual environment 27 | - Production ready virtual environment 28 | - Our first Python FastAPI Web App 29 | 30 | #### Chapter 5: Nginx & Supervisors 31 | - Nginx for Web Servers 32 | - Supervisor 33 | - Deploy & solve 34 | 35 | ## __Learn how [here](https://event.on24.com/wcc/r/3349436/AC9A63C2985E4B8622CFA5FFCAFDB1BD)__ 36 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from pydantic import BaseSettings 4 | 5 | 6 | class Settings(BaseSettings): 7 | app_name: str = "Fastapi APP" 8 | app_db: str = None 9 | 10 | class Config: 11 | env_file = ".env" 12 | 13 | 14 | @lru_cache() 15 | def get_settings(): 16 | return Settings() -------------------------------------------------------------------------------- /app/crud.py: -------------------------------------------------------------------------------- 1 | # create retrieve update and delete 2 | from sqlalchemy.orm import Session 3 | 4 | from models import Entry 5 | from schema import EntryCreateSchema 6 | 7 | def create_entry(db:Session, entry_obj:EntryCreateSchema): 8 | """ 9 | 1. Validate incoming data via EntryCreateSchema 10 | 2. Save (commit) data to database 11 | 3. Return stored data 12 | """ 13 | obj = Entry(**entry_obj.dict()) 14 | db.add(obj) 15 | db.commit() 16 | db.refresh(obj) 17 | return obj 18 | 19 | 20 | 21 | def get_entries(db:Session): 22 | return db.query(Entry).all() -------------------------------------------------------------------------------- /app/db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | from config import get_settings 5 | 6 | settings = get_settings() 7 | engine = create_engine(settings.app_db) 8 | 9 | Base = declarative_base() 10 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 11 | 12 | 13 | def create_db_and_tables(): 14 | Base.metadata.create_all(bind=engine) 15 | 16 | 17 | def get_db(): 18 | db = SessionLocal() 19 | try: 20 | yield db 21 | finally: 22 | db.close() -------------------------------------------------------------------------------- /app/ip.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | def get_ip(): 4 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 5 | try: 6 | # doesn't even have to be reachable 7 | s.connect(('10.255.255.255', 1)) 8 | IP = s.getsockname()[0] 9 | except Exception: 10 | IP = '127.0.0.1' 11 | finally: 12 | s.close() 13 | return IP -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from fastapi import FastAPI, Request, Depends 3 | from fastapi.responses import HTMLResponse 4 | from fastapi.templating import Jinja2Templates 5 | from sqlalchemy.orm import Session 6 | from typing import List 7 | 8 | 9 | from config import get_settings 10 | from crud import get_entries, create_entry 11 | from db import create_db_and_tables, get_db 12 | from ip import get_ip 13 | from schema import EntryCreateSchema, EntryListSchema 14 | 15 | BASE_DIR = pathlib.Path(__file__).resolve().parent 16 | ROOT_PROJECT_DIR = BASE_DIR.parent 17 | TEMPLATE_DIR = ROOT_PROJECT_DIR / "html" # /var/www/html/ 18 | app = FastAPI() 19 | settings = get_settings() 20 | 21 | templates = Jinja2Templates(directory=str(TEMPLATE_DIR)) 22 | 23 | 24 | @app.on_event("startup") 25 | def on_startup(): 26 | print("Starting..") 27 | create_db_and_tables() 28 | 29 | 30 | @app.get("/", response_class=HTMLResponse) # html -> localhost:8000/ 31 | def read_index(request:Request): 32 | return templates.TemplateResponse("index.nginx-debian.html", {"request": request, "title": "Hello World from Jinja", "hostname": get_ip() }) 33 | 34 | 35 | @app.get("/abc") # html -> localhost:8000/abc 36 | def read_abc(): 37 | return {"hello": "world", "db": settings.app_db is not None} 38 | 39 | 40 | @app.get("/entries/", response_model=List[EntryListSchema]) # html -> localhost:8000/abc 41 | def entry_list_view(db:Session = Depends(get_db)): 42 | return get_entries(db) 43 | 44 | 45 | @app.post("/entries/", response_model=EntryCreateSchema, status_code=201) 46 | def entry_create_view(data: EntryCreateSchema, db:Session = Depends(get_db)): 47 | return create_entry(db, data) -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.sql import func 2 | from sqlalchemy import Column, Integer, String, DateTime 3 | 4 | from db import Base 5 | """ 6 | id 7 | title 8 | content 9 | timestamp 10 | updated 11 | """ 12 | 13 | class Entry(Base): 14 | __tablename__ = 'entries' 15 | id = Column(Integer, primary_key=True, index=True) 16 | title = Column(String(100), index=False) 17 | content = Column(String, index=False, nullable=True) 18 | timestamp = Column(DateTime(timezone=True), server_default=func.now()) 19 | updated = Column(DateTime(timezone=True), onupdate=func.now()) -------------------------------------------------------------------------------- /app/prod_calls.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | endpoint = 'https://linode.tryfastapi.com/entries/' 4 | 5 | client = requests 6 | 7 | 8 | def test_entries_list(): 9 | response = client.get(endpoint) 10 | assert response.status_code == 200 11 | # assert len(response.json()) == 2 12 | 13 | def test_entries_create(): 14 | response = client.post(endpoint, json={"title": "Hello world"}) 15 | print(response.json()) 16 | assert response.status_code == 201 17 | # assert len(response.json().keys()) == 2 18 | 19 | def test_entries_create_invalid(): 20 | response = client.post(endpoint, json={"content": "Hello world"}) 21 | assert response.status_code == 422 22 | 23 | 24 | if __name__ == "__main__": 25 | test_entries_create() 26 | 27 | -------------------------------------------------------------------------------- /app/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = bin/* lib/* include/* -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.65.1,<0.66.0 2 | jinja2 3 | uvicorn 4 | gunicorn 5 | python-dotenv 6 | sqlalchemy 7 | psycopg2-binary 8 | pytest 9 | requests -------------------------------------------------------------------------------- /app/schema.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Optional 3 | from pydantic import BaseModel as BaseModelSchema, validator 4 | 5 | class EntryCreateSchema(BaseModelSchema): 6 | title: str 7 | content: Optional[str] = None 8 | 9 | class Config: 10 | orm_mode = True 11 | 12 | @validator("title") 13 | def title_length(cls, value, **kwargs): 14 | if len(value) > 100: 15 | raise ValueError(f"{value} cannot exceed 100") 16 | return value 17 | 18 | 19 | class EntryListSchema(EntryCreateSchema): 20 | id: int 21 | timestamp: Optional[datetime.datetime] = datetime.datetime.utcnow() 22 | updated: Optional[datetime.datetime] = datetime.datetime.utcnow() 23 | 24 | 25 | """ 26 | id 27 | title 28 | content 29 | timestamp 30 | updated 31 | """ -------------------------------------------------------------------------------- /app/test_schema.py: -------------------------------------------------------------------------------- 1 | import schema 2 | import pytest 3 | from pydantic import parse_obj_as 4 | from pydantic.error_wrappers import ValidationError 5 | from typing import List 6 | 7 | def test_entry_create_schema_valid(): 8 | data = {"title": "Hello world"} 9 | obj = schema.EntryCreateSchema(**data) 10 | assert obj.title == data['title'] 11 | 12 | 13 | def test_entry_create_schema_strips_data_valid(): 14 | data = {"title": "Hello world", "abc": "123"} 15 | obj = schema.EntryCreateSchema(**data) 16 | obj_keys = obj.dict().keys() 17 | assert len(obj_keys) == 2 18 | assert set(obj_keys) != set(data.keys()) 19 | 20 | 21 | 22 | def test_entry_create_schema_title_len_valid(): 23 | max_length = 100 24 | title_ = "".join(["a" for x in range(max_length)]) 25 | data = {"title": title_} 26 | schema.EntryCreateSchema(**data) 27 | 28 | def test_entry_create_schema_title_len_invalid(): 29 | max_length = 100 30 | with pytest.raises(ValidationError): 31 | title_ = "".join(["a" for x in range(max_length + 1)]) 32 | data = {"title": title_} 33 | schema.EntryCreateSchema(**data) 34 | 35 | 36 | def test_entry_create_schema_invalid(): 37 | with pytest.raises(ValidationError): 38 | data = {"content": "Hello world"} 39 | schema.EntryCreateSchema(**data) 40 | 41 | 42 | def test_entries_list_schema_invalid_ids(): 43 | items = [ 44 | {"title": "Hello world", "content": "again"}, 45 | {"title": "Hello worlder", "content": "abc"}, 46 | ] 47 | with pytest.raises(ValidationError): 48 | obj_list = parse_obj_as(List[schema.EntryListSchema], items) 49 | 50 | 51 | def test_entries_list_schema_valid_ids(): 52 | items = [ 53 | {"id": 1, "title": "Hello world", "content": "again"}, 54 | {"id": 2, "title": "Hello worlder", "content": "abc"}, 55 | ] 56 | obj_list = parse_obj_as(List[schema.EntryListSchema], items) 57 | first_obj = obj_list[0] 58 | assert len(first_obj.dict().keys()) == 5 -------------------------------------------------------------------------------- /app/test_views.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient # requests 2 | from main import app 3 | 4 | client = TestClient(app) 5 | 6 | 7 | def test_entries_list(): 8 | response = client.get("/entries/") 9 | assert response.status_code == 200 10 | # assert len(response.json()) == 2 11 | 12 | def test_entries_create(): 13 | response = client.post("/entries/", json={"title": "Hello world"}) 14 | assert response.status_code == 201 15 | # assert len(response.json().keys()) == 2 16 | 17 | def test_entries_create_invalid(): 18 | response = client.post("/entries/", json={"content": "Hello world"}) 19 | assert response.status_code == 422 -------------------------------------------------------------------------------- /html/index.nginx-debian.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |Hello from the awesomer LB
16 | 17 |For online documentation and support please refer to
18 | nginx.org.
19 | Commercial support is available at
20 | nginx.com.
Thank you for using nginx.
23 | 24 | 25 | -------------------------------------------------------------------------------- /inventory.ini: -------------------------------------------------------------------------------- 1 | [webapp] 2 | 45.79.100.43 3 | 96.126.103.230 4 | 50.116.0.35 -------------------------------------------------------------------------------- /nginx/my_lb.conf: -------------------------------------------------------------------------------- 1 | upstream myproxy { 2 | server 45.79.100.43; 3 | server 96.126.103.230; 4 | server 50.116.0.35; 5 | } 6 | 7 | server { 8 | listen 80; 9 | server_name linode.tryfastapi.com; 10 | root /var/www/html; 11 | 12 | location / { 13 | proxy_pass http://myproxy; 14 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 15 | proxy_set_header Host $host; 16 | proxy_redirect off; 17 | } 18 | } 19 | 20 | server { 21 | listen: 80; 22 | server_name 45.79.81.206; 23 | return 301 $scheme://linode.tryfastapi.com$request_uri; 24 | 25 | } -------------------------------------------------------------------------------- /nginx/myapp.conf: -------------------------------------------------------------------------------- 1 | upstream myproxy { 2 | server localhost:8000; 3 | } 4 | 5 | server { 6 | listen 80; 7 | server_name localhost; 8 | root /var/www/html; 9 | 10 | location / { 11 | proxy_pass http://myproxy; 12 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 13 | proxy_set_header Host $host; 14 | proxy_redirect off; 15 | } 16 | } -------------------------------------------------------------------------------- /postgresql-server/reference.md: -------------------------------------------------------------------------------- 1 | ``` 2 | APP_DB="fast" 3 | 4 | APP_DB_USER="fastuser" 5 | 6 | APP_DB_PASSWORD="uLEZ-9bFAT0xDMIx2LnMj8ZelT6johrh7iX4E-zazQo" 7 | ``` 8 | 9 | ``` 10 | psql --command="CREATE DATABASE ${APP_DB};" 11 | psql --command="CREATE USER ${APP_DB_USER} WITH PASSWORD '${APP_DB_PASSWORD}';" 12 | psql --command="ALTER ROLE ${APP_DB_USER} SET client_encoding TO 'utf8';" 13 | psql --command="ALTER ROLE ${APP_DB_USER} SET default_transaction_isolation TO 'read committed';" 14 | psql --command="ALTER ROLE ${APP_DB_USER} SET timezone TO 'UTC';" 15 | psql --command="GRANT ALL PRIVILEGES ON DATABASE ${APP_DB} to ${APP_DB_USER};" 16 | ``` 17 | 18 | 19 | ``` 20 | psql -Atx postgresql://fastuser:uLEZ-9bFAT0xDMIx2LnMj8ZelT6johrh7iX4E-zazQo@50.116.0.232/fast 21 | ``` 22 | > `psql -Atx postgresql://