From 279c6b721388f324378460363e8e0850fb1edf4c Mon Sep 17 00:00:00 2001 From: Julian Lobbes Date: Thu, 17 Aug 2023 18:19:46 +0200 Subject: [PATCH] feat: add production setup files --- README.md | 10 ++- app/core/settings.py | 15 ++--- app/core/utils.py | 27 +++++++++ app/requirements.txt | 3 + development.docker-compose.yml | 2 +- production.caddy.Dockerfile | 24 ++++++++ production.django.Dockerfile | 33 ++++++++++ production.docker-compose.yml | 108 +++++++++++++++++++++++++++++++++ production.supervisord.conf | 17 ++++++ 9 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 app/core/utils.py create mode 100644 production.caddy.Dockerfile create mode 100644 production.django.Dockerfile create mode 100644 production.docker-compose.yml create mode 100644 production.supervisord.conf diff --git a/README.md b/README.md index ff3ba5b..d500b36 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ The file contains the following environment variables: ```conf TIMEZONE=Europe/Berlin +DJANGO_DEBUG_MODE=false +DJANGO_SECRET_KEY=abc123mySecret PG_NAME=medwings PG_USER=medwings PG_PASSWORD=secret @@ -46,9 +48,11 @@ You should set the values of the following variables: | variable | description | value | |----------|-------------|-------| -| PG_PASSWORD | password for the PostgreSQL admin user | a random string of 32 characters | -| GOTIFY_USER | name of the Gotify admin user | a random string of 32 characters | -| GOTIFY_PASSWORD | password for the Gotify admin user | a random string of 32 characters | +| DJANGO_DEBUG_MODE | whether or not to enable Django's debug mode | 'true' during development and 'false' in production | +| DJANGO_SECRET_KEY | private session secret | a random string of 64 characters or more | +| PG_PASSWORD | password for the PostgreSQL admin user | a random string of 32 characters or more | +| GOTIFY_USER | name of the Gotify admin user | a random string of 32 characters or more | +| GOTIFY_PASSWORD | password for the Gotify admin user | a random string of 32 characters or more | | GOTIFY_PUBLIC_URL | URL where your public Gotify server can be reached | this depends on your deployment environment | | WITHINGS_CLIENT_ID | Your Withings API client id | see [Withings API](./app/withings/README.md#api-access) | | WITHINGS_CLIENT_SECRET | Your Withings API client secret | see [Withings API](./app/withings/README.md#api-access) | diff --git a/app/core/settings.py b/app/core/settings.py index 77661a4..4f1962e 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/4.2/ref/settings/ from pathlib import Path from os import getenv +from utils import parse_string_as_bool # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -21,14 +22,10 @@ BASE_DIR = Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-s^q)z%f-7=1h5b00ctki2*-w=#3!k@p-#sq%=eajw)x2axx-e5' +SECRET_KEY = getenv('DJANGO_SECRET_KEY') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True -ALLOWED_HOSTS = [ - 'localhost', - '127.0.0.1', - '192.168.2.141' -] +DEBUG = parse_string_as_bool(getenv('DJANGO_DEBUG_MODE', 'false')) +ALLOWED_HOSTS = [ '*' ] # Application definition @@ -80,8 +77,8 @@ DATABASES = { 'ENGINE': 'django.db.backends.postgresql', 'NAME': getenv('PG_NAME', 'medwings'), 'USER': getenv('PG_USER', 'medwings'), - 'PASSWORD': getenv('PG_PASSWORD', 'medwings'), - 'HOST': getenv('PG_HOST', 'medwings-postgres'), + 'PASSWORD': getenv('PG_PASSWORD'), + 'HOST': getenv('PG_HOST'), 'PORT': getenv('PG_PORT', '5432'), } } diff --git a/app/core/utils.py b/app/core/utils.py new file mode 100644 index 0000000..b894aa6 --- /dev/null +++ b/app/core/utils.py @@ -0,0 +1,27 @@ +"""Miscellaneous utility functions.""" + +import re + + +def parse_string_as_bool(value: str) -> bool: + """Parses the given string into a boolean based on its content. + + This is used to parse environment variables as boolean values. + + The following strings are parsed as `True`: "yes", "Yes", "YES", "true", "True", "TRUE", "1" + The following strings are parsed as `False`: "no", "No", "NO", "false", "False", "FALSE", "0" + + In any other case, a `ValueError` is raised. + """ + + if not isinstance(value, str): + raise TypeError("Expected a string argument.") + + regex_true = re.compile(r"^(YES)|(Yes)|(yes)|(TRUE)|(True)|(true)|(1)$") + regex_false = re.compile(r"^(NO)|(No)|(no)|(FALSE)|(False)|(false)|(0)$") + + if regex_true.fullmatch(value): + return True + if regex_false.fullmatch(value): + return False + raise ValueError(f"Failed to parse the supplied value as a boolean: {value!r}") diff --git a/app/requirements.txt b/app/requirements.txt index 72d30a5..0193a78 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,9 +1,11 @@ asgiref==3.7.2 certifi==2023.7.22 charset-normalizer==3.2.0 +click==8.1.6 Django==4.2.3 django-widget-tweaks==1.4.12 djangorestframework==3.14.0 +h11==0.14.0 idna==3.4 psycopg==3.1.9 psycopg-binary==3.1.9 @@ -12,3 +14,4 @@ requests==2.31.0 sqlparse==0.4.4 typing_extensions==4.7.1 urllib3==2.0.4 +uvicorn==0.23.2 diff --git a/development.docker-compose.yml b/development.docker-compose.yml index d3642f0..22f4ab0 100644 --- a/development.docker-compose.yml +++ b/development.docker-compose.yml @@ -26,7 +26,7 @@ services: - ${PG_HOST} build: context: . - dockerfile: ./development.django.Dockerfile + dockerfile: development.django.Dockerfile args: CUSTOM_UID: 1000 CUSTOM_GID: 1000 diff --git a/production.caddy.Dockerfile b/production.caddy.Dockerfile new file mode 100644 index 0000000..11370fe --- /dev/null +++ b/production.caddy.Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +FROM alpine:latest + +# Install caddy +RUN apk add --no-cache caddy + +# Create non-root user +ARG CUSTOM_UID +ARG CUSTOM_GID +ENV CUSTOM_USERNAME=webserver +ENV CUSTOM_GROUPNAME=webserver +RUN addgroup --gid ${CUSTOM_GID:-1000} ${CUSTOM_GROUPNAME} && \ + adduser --uid ${CUSTOM_UID:-1000} --shell /bin/ash ${CUSTOM_USERNAME} --ingroup ${CUSTOM_GROUPNAME} --disabled-password && \ + mkdir /app && chown ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000} /app && chmod 700 /app + +# Copy caddy config +WORKDIR /app +COPY --chown=${CUSTOM_USERNAME}:${CUSTOM_GROUPNAME} Caddyfile /app/ + +# Run Caddy in development mode +USER ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000} +EXPOSE 8000 +ENTRYPOINT ["caddy", "run", "--config", "/app/Caddyfile", "--adapter", "caddyfile"] diff --git a/production.django.Dockerfile b/production.django.Dockerfile new file mode 100644 index 0000000..d763ee2 --- /dev/null +++ b/production.django.Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +FROM python:alpine + +# Install cron daemon and supervisord +RUN apk add --no-cache dcron supervisor + +# Create non-root user +ARG CUSTOM_UID +ARG CUSTOM_GID +ENV CUSTOM_USERNAME=django +ENV CUSTOM_GROUPNAME=django +RUN addgroup --gid ${CUSTOM_GID:-1000} ${CUSTOM_GROUPNAME} && \ + adduser --uid ${CUSTOM_UID:-1000} --shell /bin/ash ${CUSTOM_USERNAME} --ingroup ${CUSTOM_GROUPNAME} --disabled-password && \ + mkdir /app && chown ${CUSTOM_UID:-1000}:${CUSTOM_GID:-1000} /app && chmod 700 /app +ENV PATH "$PATH:/home/${CUSTOM_GROUPNAME}/.local/bin" + +# Add supervisord conf +COPY production.supervisord.conf /etc/supervisord.conf + +# Add cron job +COPY --chmod=600 django.crontab /etc/crontabs/django + +# Copy source files +WORKDIR /app +COPY --chown=${CUSTOM_USERNAME}:${CUSTOM_GROUPNAME} app/ /app/ + +# Install dependencies +RUN pip install -r requirements.txt + +# Run supervisord +EXPOSE 8000/tcp +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] diff --git a/production.docker-compose.yml b/production.docker-compose.yml new file mode 100644 index 0000000..6194988 --- /dev/null +++ b/production.docker-compose.yml @@ -0,0 +1,108 @@ +--- + +version: "3" + +services: + medwings-caddy: + container_name: medwings-caddy + restart: unless-stopped + build: + context: . + dockerfile: production.caddy.Dockerfile + args: + CUSTOM_UID: 1000 + CUSTOM_GID: 1000 + expose: + - "8000" + networks: + - medwings + - proxy + environment: + TZ: ${TIMEZONE} + labels: + - "traefik.enable=true" + - "traefik.http.routers.medwings.entrypoints=https" + - "traefik.http.routers.medwings.rule=Host(`medwings.lobbes.dev`)" + - "traefik.http.routers.medwings-secure.middlewares=default@file" + - "traefik.http.routers.medwings.tls=true" + - "traefik.http.services.medwings.loadbalancer.server.port=8000" + - "traefik.docker.network=proxy" + medwings-django: + container_name: medwings-django + restart: unless-stopped + depends_on: + - medwings-caddy + - ${PG_HOST} + build: + context: . + dockerfile: production.django.Dockerfile + args: + CUSTOM_UID: 1000 + CUSTOM_GID: 1000 + expose: + - "8000" + networks: + - medwings + environment: + TZ: ${TIMEZONE} + PG_NAME: ${PG_NAME} + PG_USER: ${PG_USER} + PG_PASSWORD: ${PG_PASSWORD} + PG_HOST: ${PG_HOST} + PG_PORT: ${PG_PORT} + WITHINGS_CLIENT_ID: ${WITHINGS_CLIENT_ID} + WITHINGS_CLIENT_SECRET: ${WITHINGS_CLIENT_SECRET} + GOTIFY_USER: ${GOTIFY_USER} + GOTIFY_PASSWORD: ${GOTIFY_PASSWORD} + GOTIFY_HOST: ${GOTIFY_HOST} + GOTIFY_PUBLIC_URL: ${GOTIFY_PUBLIC_URL} + medwings-postgres: + image: postgres:alpine + container_name: ${PG_HOST} + restart: unless-stopped + expose: + - ${PG_PORT} + networks: + - medwings + volumes: + - /srv/medwings/db:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${PG_NAME} + POSTGRES_USER: ${PG_USER} + POSTGRES_PASSWORD: ${PG_PASSWORD} + TZ: ${TIMEZONE} + medwings-gotify: + image: gotify/server + container_name: medwings-gotify + restart: unless-stopped + expose: + - "80" + networks: + - medwings + - proxy + volumes: + - /srv/medwings/gotify:/app/data + environment: + TZ: ${TIMEZONE} + GOTIFY_SERVER_SSL_REDIRECTTOHTTPS: false + GOTIFY_DEFAULTUSER_NAME: ${GOTIFY_USER} + GOTIFY_DEFAULTUSER_PASS: ${GOTIFY_PASSWORD} + GOTIFY_SERVER_CORS_ALLOWORIGINS: "- \"medwings-django:8000\"\n- \"medwings-notifications.lobbes.dev\"\n- \"medwings.lobbes.dev\"" + GOTIFY_SERVER_CORS_ALLOWMETHODS: "- \"GET\"\n- \"POST\"" + GOTIFY_SERVER_CORS_ALLOWHEADERS: "- \"Authorization\"\n- \"content-type\"" + labels: + - "traefik.enable=true" + - "traefik.http.routers.medwings-notifications.entrypoints=https" + - "traefik.http.routers.medwings-notifications.rule=Host(`medwings-notifications.lobbes.dev`)" + - "traefik.http.routers.medwings-notifications-secure.middlewares=default@file" + - "traefik.http.routers.medwings-notifications.tls=true" + - "traefik.http.services.medwings-notifications.loadbalancer.server.port=80" + - "traefik.docker.network=proxy" + +networks: + medwings: + external: false + proxy: + external: true + +... diff --git a/production.supervisord.conf b/production.supervisord.conf new file mode 100644 index 0000000..d77cd55 --- /dev/null +++ b/production.supervisord.conf @@ -0,0 +1,17 @@ +[supervisord] +nodaemon=true +user=root + +[program:django] +command=sh -c 'uvicorn core.asgi:application --host 0.0.0.0 --port 8000 --access-log' +directory=/app +user=django +autostart=true +autorestart=true +redirect_stderr=true + +[program:crond] +command=crond -f +autostart=true +autorestart=true +redirect_stderr=true