Skip to content

👷 Add continuous deployment and refactors needed for it #667

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 32 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
38d7315
📝 Add docs about continuous deployment
tiangolo Mar 10, 2024
2e7e23e
👷 Add first version of deployment GitHub Actions
tiangolo Mar 10, 2024
e01bd77
🔧 Add DOMAIN_STAGING and DOMAIN_PRODUCTION env vars
tiangolo Mar 10, 2024
3acd1bd
🔀 Merge branch 'master' into cd
tiangolo Mar 10, 2024
4369524
♻️ Refactor and update config.py
tiangolo Mar 11, 2024
a0b5d55
♻️ Refactor config to decide scheme (http or https) depending on envi…
tiangolo Mar 11, 2024
b23ae93
🔧 Update .env config
tiangolo Mar 11, 2024
9ca0cae
🔧 Update usage of environment variables in Docker Compose
tiangolo Mar 11, 2024
8074392
🔧 Enable Traefik debug logs for local development
tiangolo Mar 11, 2024
381afc8
♻️ Refactor usage of DOMAIN env var
tiangolo Mar 11, 2024
bc37e21
🔧 Update .env file with single DOMAIN
tiangolo Mar 11, 2024
4f7a376
🔧 Remove explicit traefik network
tiangolo Mar 11, 2024
53f3d9a
🔥 Remove DOMAIN from Copier configs, it should be left as localhost b…
tiangolo Mar 11, 2024
54b23a6
👷 Update env vars and secrets for continuous deployment
tiangolo Mar 11, 2024
4e5db41
✅ Fix test mocks, I don't like mocks
tiangolo Mar 11, 2024
ec77f7e
🔧 Disable www redirection extra logic
tiangolo Mar 11, 2024
cbee7dc
🔧 Re-enable traefik-public network config
tiangolo Mar 11, 2024
87f7d90
🔧 Update deployment GitHub Actions config, use env vars for both Dock…
tiangolo Mar 11, 2024
f8c6936
🔧 Re-enable www redirect
tiangolo Mar 11, 2024
39733d3
✅ Update mocks in tests
tiangolo Mar 11, 2024
8a2e755
🔧 Update GitHub Actions names
tiangolo Mar 11, 2024
1f5f876
🔧 Revert Traefik Docker Compose comment
tiangolo Mar 11, 2024
73a2d35
🔧 Remove non-redirect route
tiangolo Mar 11, 2024
b06aa7b
🔧 Remove comment setting with no www redirect
tiangolo Mar 11, 2024
4ae274c
🔧 Remove temporary comment config
tiangolo Mar 11, 2024
2e4632d
🔧 Move GitHub Actions configs
tiangolo Mar 11, 2024
8c28ad1
🔧 Deploy on cd branch for testing
tiangolo Mar 11, 2024
eb6184f
👷 Trigger CI
tiangolo Mar 11, 2024
0b20f23
🔧 Remove temporary cd branch deployment
tiangolo Mar 11, 2024
8b4d6e0
🔧 Update GitHub Action workflows with env vars
tiangolo Mar 11, 2024
0cf1d4b
🔧 Add restarts to Docker Compose files
tiangolo Mar 11, 2024
180bb08
📝 Update the deployment docs
tiangolo Mar 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .env
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
# Update this with your app domain
# Domain
# This would be set to the production domain with an env var on deployment
DOMAIN=localhost
# DOMAIN=localhost.tiangolo.com

SERVER_HOST=http://localhost
# Environment: local, staging, production
ENVIRONMENT=local

PROJECT_NAME="FastAPI Project"

STACK_NAME=fastapi-project

# Backend
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
SECRET_KEY=changethis
[email protected]
FIRST_SUPERUSER_PASSWORD=changethis
USERS_OPEN_REGISTRATION=False

# Emails
SMTP_HOST=
SMTP_USER=
SMTP_PASSWORD=
[email protected]
SMTP_TLS=True
SMTP_PORT=587

USERS_OPEN_REGISTRATION=False

# Postgres
POSTGRES_SERVER=db
POSTGRES_USER=postgres
POSTGRES_SERVER=localhost
POSTGRES_DB=app
POSTGRES_USER=postgres
POSTGRES_PASSWORD=changethis

# PgAdmin
Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Deploy to Production

on:
release:
types:
- published

jobs:
deploy:
runs-on:
- self-hosted
- production
env:
ENVIRONMENT: production
DOMAIN: ${{ secrets.DOMAIN_PRODUCTION }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
PGADMIN_DEFAULT_EMAIL: ${{ secrets.PGADMIN_DEFAULT_EMAIL }}
PGADMIN_DEFAULT_PASSWORD: ${{ secrets.PGADMIN_DEFAULT_PASSWORD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
FLOWER_BASIC_AUTH: ${{ secrets.FLOWER_BASIC_AUTH }}
steps:
- name: Checkout
uses: actions/checkout@v4
- run: docker compose -f docker-compose.yml build
- run: docker compose -f docker-compose.yml up -d
32 changes: 32 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Deploy to Staging

on:
push:
branches:
- master

jobs:
deploy:
runs-on:
- self-hosted
- staging
env:
ENVIRONMENT: staging
DOMAIN: ${{ secrets.DOMAIN_STAGING }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
PGADMIN_DEFAULT_EMAIL: ${{ secrets.PGADMIN_DEFAULT_EMAIL }}
PGADMIN_DEFAULT_PASSWORD: ${{ secrets.PGADMIN_DEFAULT_PASSWORD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
FLOWER_BASIC_AUTH: ${{ secrets.FLOWER_BASIC_AUTH }}
steps:
- name: Checkout
uses: actions/checkout@v4
- run: docker compose -f docker-compose.yml build
- run: docker compose -f docker-compose.yml up -d
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ But don't worry, you can just update any of that in the `.env` files afterwards.

The input variables, with their default values (some auto generated) are:

- `domain`: (default: `"localhost"`) Which domain name to use for the project, by default, localhost, but you should change it later (in .env).
- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env).
- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels (no spaces) (in .env).
- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above.
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
)

user = crud.create_user(session=session, user_create=user_in)
if settings.EMAILS_ENABLED and user_in.email:
if settings.emails_enabled and user_in.email:
email_data = generate_new_account_email(
email_to=user_in.email, username=user_in.email, password=user_in.password
)
Expand Down
102 changes: 51 additions & 51 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,95 +1,95 @@
import secrets
from typing import Any
from typing import Annotated, Any, Literal

from pydantic import (
AnyHttpUrl,
AnyUrl,
BeforeValidator,
HttpUrl,
PostgresDsn,
ValidationInfo,
field_validator,
computed_field,
model_validator,
)
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing_extensions import Self


def parse_cors(v: Any) -> list[str] | str:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, list | str):
return v
raise ValueError(v)


class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_ignore_empty=True)
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = secrets.token_urlsafe(32)
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
SERVER_HOST: AnyHttpUrl
BACKEND_CORS_ORIGINS: list[AnyHttpUrl] | str = []

@field_validator("BACKEND_CORS_ORIGINS", mode="before")
@classmethod
def assemble_cors_origins(cls, v: str | list[str]) -> list[str] | str:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, list | str):
return v
raise ValueError(v)
DOMAIN: str = "localhost"
ENVIRONMENT: Literal["local", "staging", "production"] = "local"

PROJECT_NAME: str
SENTRY_DSN: HttpUrl | None = None
@computed_field # type: ignore[misc]
@property
def server_host(self) -> str:
# Use HTTPS for anything other than local development
if self.ENVIRONMENT == "local":
return f"http://{self.DOMAIN}"
return f"https://{self.DOMAIN}"

@field_validator("SENTRY_DSN", mode="before")
@classmethod
def sentry_dsn_can_be_blank(cls, v: str) -> str | None:
if not v:
return None
return v
BACKEND_CORS_ORIGINS: Annotated[
list[AnyUrl] | str, BeforeValidator(parse_cors)
] = []

PROJECT_NAME: str
SENTRY_DSN: HttpUrl | None = None
POSTGRES_SERVER: str
POSTGRES_USER: str
POSTGRES_PASSWORD: str
POSTGRES_DB: str
SQLALCHEMY_DATABASE_URI: PostgresDsn | None = None

@field_validator("SQLALCHEMY_DATABASE_URI", mode="before")
def assemble_db_connection(cls, v: str | None, info: ValidationInfo) -> Any:
if isinstance(v, str):
return v
return PostgresDsn.build(
POSTGRES_DB: str = ""

@computed_field # type: ignore[misc]
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
return MultiHostUrl.build(
scheme="postgresql+psycopg",
username=info.data.get("POSTGRES_USER"),
password=info.data.get("POSTGRES_PASSWORD"),
host=info.data.get("POSTGRES_SERVER"),
path=f"{info.data.get('POSTGRES_DB') or ''}",
username=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD,
host=self.POSTGRES_SERVER,
path=self.POSTGRES_DB,
)

SMTP_TLS: bool = True
SMTP_PORT: int | None = None
SMTP_PORT: int = 587
SMTP_HOST: str | None = None
SMTP_USER: str | None = None
SMTP_PASSWORD: str | None = None
# TODO: update type to EmailStr when sqlmodel supports it
EMAILS_FROM_EMAIL: str | None = None
EMAILS_FROM_NAME: str | None = None

@field_validator("EMAILS_FROM_NAME")
def get_project_name(cls, v: str | None, info: ValidationInfo) -> str:
if not v:
return str(info.data["PROJECT_NAME"])
return v
@model_validator(mode="after")
def set_default_emails_from(self) -> Self:
if not self.EMAILS_FROM_NAME:
self.EMAILS_FROM_NAME = self.PROJECT_NAME
return self

EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build"
EMAILS_ENABLED: bool = False

@field_validator("EMAILS_ENABLED", mode="before")
def get_emails_enabled(cls, v: bool, info: ValidationInfo) -> bool:
return bool(
info.data.get("SMTP_HOST")
and info.data.get("SMTP_PORT")
and info.data.get("EMAILS_FROM_EMAIL")
)

@computed_field # type: ignore[misc]
@property
def emails_enabled(self) -> bool:
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)

# TODO: update type to EmailStr when sqlmodel supports it
EMAIL_TEST_USER: str = "[email protected]"
# TODO: update type to EmailStr when sqlmodel supports it
FIRST_SUPERUSER: str
FIRST_SUPERUSER_PASSWORD: str
USERS_OPEN_REGISTRATION: bool = False
model_config = SettingsConfigDict(env_file=".env")


settings = Settings() # type: ignore
3 changes: 2 additions & 1 deletion backend/app/tests/api/api_v1/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ def test_recovery_password(
client: TestClient, normal_user_token_headers: dict[str, str], mocker: MockerFixture
) -> None:
mocker.patch("app.utils.send_email", return_value=None)
mocker.patch("app.core.config.settings.EMAILS_ENABLED", True)
mocker.patch("app.core.config.settings.SMTP_HOST", "smtp.example.com")
mocker.patch("app.core.config.settings.SMTP_USER", "[email protected]")
email = "[email protected]"
r = client.post(
f"{settings.API_V1_STR}/password-recovery/{email}",
Expand Down
3 changes: 2 additions & 1 deletion backend/app/tests/api/api_v1/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ def test_create_user_new_email(
mocker: MockerFixture,
) -> None:
mocker.patch("app.utils.send_email")
mocker.patch("app.core.config.settings.EMAILS_ENABLED", True)
mocker.patch("app.core.config.settings.SMTP_HOST", "smtp.example.com")
mocker.patch("app.core.config.settings.SMTP_USER", "[email protected]")
username = random_email()
password = random_lower_string()
data = {"email": username, "password": password}
Expand Down
10 changes: 4 additions & 6 deletions backend/app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class EmailData:
subject: str


def render_email_template(*, template_name: str, context: dict[str, Any]):
def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
template_str = (Path(settings.EMAIL_TEMPLATES_DIR) / template_name).read_text()
html_content = Template(template_str).render(context)
return html_content
Expand All @@ -29,7 +29,7 @@ def send_email(
subject: str = "",
html_content: str = "",
) -> None:
assert settings.EMAILS_ENABLED, "no provided configuration for email variables"
assert settings.emails_enabled, "no provided configuration for email variables"
message = emails.Message(
subject=subject,
html=html_content,
Expand Down Expand Up @@ -59,8 +59,7 @@ def generate_test_email(email_to: str) -> EmailData:
def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Password recovery for user {email}"
server_host = settings.SERVER_HOST
link = f"{server_host}/reset-password?token={token}"
link = f"{settings.server_host}/reset-password?token={token}"
html_content = render_email_template(
template_name="reset_password.html",
context={
Expand All @@ -79,15 +78,14 @@ def generate_new_account_email(
) -> EmailData:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - New account for user {username}"
link = settings.SERVER_HOST
html_content = render_email_template(
template_name="new_account.html",
context={
"project_name": settings.PROJECT_NAME,
"username": username,
"password": password,
"email": email_to,
"link": link,
"link": settings.server_host,
},
)
return EmailData(html_content=html_content, subject=subject)
Expand Down
7 changes: 0 additions & 7 deletions copier.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
domain:
type: str
help: |
Which domain name to use for the project, by default,
localhost, but you should change it later (in .env)
default: localhost

project_name:
type: str
help: The name of the project, shown to API users (in .env)
Expand Down
Loading