feat: consolidate deployment targets and publish to ghcr.io (#2539)

* WIP: proof of concept

* basic meta tag injection

* add support for scraping public/private links

* make tests go brrrrr

* cleanup initialization

* rewrite build config

* remove recipe meta on frontend

* make type checker happy

* remove other deployment methods

* fix issue with JSON response on un-authenticated request

* docs updates

* update tivy scanner

* fix linter stuff

* change registry tag

* build fixes

* fix same mistake I always make
This commit is contained in:
Hayden 2023-09-14 06:40:13 -08:00 committed by GitHub
parent aec4cb4f31
commit 2ad6af2cce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 268 additions and 793 deletions

View File

@ -41,24 +41,4 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_NIGHTLY_WEBHOOK }}
uses: Ilshidur/action-discord@0.3.2
with:
args: "🚀 New builds of mealie:api-nightly, mealie:frontend-nightly, and mealie:omni-nightly are available"
deploy-demo:
runs-on: ubuntu-latest
name: Deploy Demo
needs:
- build-release
steps:
- name: Clean and Deploy Demo
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEMO_SERVER_IP }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
port: ${{ secrets.DEMO_SERVER_PORT }}
script_stop: true
script: |
cd ~/docker/mealie
docker-compose pull
docker-compose down -v
docker-compose up -d
args: "🚀 New builds of ghcr.io/mealie-recipes/mealie:nightly"

View File

@ -13,163 +13,37 @@ on:
required: true
jobs:
build-frontend:
publish:
runs-on: ubuntu-latest
name: Build Frontend
permissions:
contents: read
packages: write
steps:
- name: Checkout
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
with:
image: tonistiigi/binfmt:latest
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: |
hkotel/mealie
ghcr.io/${{ github.repository }}
- name: Build and push Frontend images
uses: docker/build-push-action@v4
with:
file: docker/frontend.Dockerfile
context: .
push: true
tags: frontend-${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
build-backend:
runs-on: ubuntu-latest
name: Build Backend
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
with:
image: tonistiigi/binfmt:latest
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Override __init__.py
run: |
echo "__version__ = \"${{ inputs.tag }}\"" > ./mealie/__init__.py
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: |
hkotel/mealie
ghcr.io/${{ github.repository }}
- name: Build and push API images
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
file: docker/api.Dockerfile
file: ./docker/Dockerfile
context: .
push: true
tags: api-${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
build-omni:
runs-on: ubuntu-latest
name: Build Omni
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
with:
image: tonistiigi/binfmt:latest
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Override __init__.py
run: |
echo "__version__ = \"${{ inputs.tag }}\"" > ./mealie/__init__.py
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: |
hkotel/mealie
ghcr.io/${{ github.repository }}
- name: Build and push API images
uses: docker/build-push-action@v4
with:
file: docker/omni.Dockerfile
context: .
push: true
tags: omni-${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository }}:${{ inputs.tag }}

View File

@ -1,11 +1,11 @@
name: Trivy Backend Container Scanning
name: Trivy Container Scanning
on:
workflow_call:
jobs:
build:
name: Build and Scan Backend Container
name: Build and Scan Container
runs-on: ubuntu-latest
strategy:
fail-fast: true
@ -15,7 +15,7 @@ jobs:
- name: Build Dockerfile
run: |
docker build -t mealie --file=./docker/api.Dockerfile .
docker build -t mealie --file=./docker/Dockerfile .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master

View File

@ -1,31 +0,0 @@
name: Trivy Frontend Container Scanning
on:
workflow_call:
jobs:
build:
name: Build and Scan Frontend Container
runs-on: ubuntu-latest
strategy:
fail-fast: true
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Build Dockerfile
run: |
docker build -t mealie --file=./docker/frontend.Dockerfile .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
ignore-unfixed: true
image-ref: "mealie"
format: "sarif"
output: "trivy-results.sarif"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: "trivy-results.sarif"

View File

@ -14,10 +14,6 @@ jobs:
name: "Frontend and End-to-End Tests"
uses: ./.github/workflows/partial-frontend.yml
backend-container-scanning:
name: "Trivy Backend Container Scanning"
uses: ./.github/workflows/partial-trivy-backend-container-scanning.yml
frontend-container-scanning:
name: "Trivy Frontend Container Scanning"
uses: ./.github/workflows/partial-trivy-frontend-container-scanning.yml
container-scanning:
name: "Trivy Container Scanning"
uses: ./.github/workflows/partial-trivy-container-scanning.yml

View File

@ -12,14 +12,7 @@ RUN yarn install \
# https://github.com/docker/build-push-action/issues/471
--network-timeout 1000000
RUN yarn build
RUN rm -rf node_modules && \
NODE_ENV=production yarn install \
--prefer-offline \
--pure-lockfile \
--non-interactive \
--production=true
RUN yarn generate
###############################################
# Base Image - Python
@ -150,12 +143,13 @@ HEALTHCHECK CMD python $MEALIE_HOME/mealie/scripts/healthcheck.py || exit 1
# Copy Frontend
# copying caddy into image
COPY --from=builder /app $MEALIE_HOME/frontend/
ENV STATIC_FILES=/spa/static
COPY --from=builder /app/dist ${STATIC_FILES}
ENV HOST 0.0.0.0
EXPOSE ${APP_PORT}
COPY ./docker/omni.entry.sh $MEALIE_HOME/run.sh
COPY ./docker/entry.sh $MEALIE_HOME/run.sh
RUN chmod +x $MEALIE_HOME/run.sh
ENTRYPOINT $MEALIE_HOME/run.sh

View File

@ -1,115 +0,0 @@
###############################################
# Base Image
###############################################
FROM python:3.10-slim as python-base
ENV MEALIE_HOME="/app"
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_HOME="/opt/poetry" \
POETRY_VIRTUALENVS_IN_PROJECT=true \
POETRY_NO_INTERACTION=1 \
PYSETUP_PATH="/opt/pysetup" \
VENV_PATH="/opt/pysetup/.venv"
# prepend poetry and venv to path
ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH"
# create user account
RUN useradd -u 911 -U -d $MEALIE_HOME -s /bin/bash abc \
&& usermod -G users abc \
&& mkdir $MEALIE_HOME
###############################################
# Builder Image
###############################################
FROM python-base as builder-base
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
curl \
build-essential \
libpq-dev \
libwebp-dev \
tesseract-ocr-all \
# LDAP Dependencies
libsasl2-dev libldap2-dev libssl-dev \
gnupg gnupg2 gnupg1 \
&& pip install -U --no-cache-dir pip
# install poetry - respects $POETRY_VERSION & $POETRY_HOME
ENV POETRY_VERSION=1.3.1
RUN curl -sSL https://install.python-poetry.org | python3 -
# copy project requirement files here to ensure they will be cached.
WORKDIR $PYSETUP_PATH
COPY ./poetry.lock ./pyproject.toml ./
# install runtime deps - uses $POETRY_VIRTUALENVS_IN_PROJECT internally
RUN poetry install -E pgsql --only main
###############################################
# CRFPP Image
###############################################
FROM hkotel/crfpp as crfpp
RUN echo "crfpp-container"
###############################################
# Production Image
###############################################
FROM python-base as production
ENV PRODUCTION=true
ENV TESTING=false
ARG COMMIT
ENV GIT_COMMIT_HASH=$COMMIT
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
gosu \
iproute2 \
tesseract-ocr-all \
libldap-common \
&& apt-get autoremove \
&& rm -rf /var/lib/apt/lists/*
# copying poetry and venv into image
COPY --from=builder-base $POETRY_HOME $POETRY_HOME
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH
ENV LD_LIBRARY_PATH=/usr/local/lib
COPY --from=crfpp /usr/local/lib/ /usr/local/lib
COPY --from=crfpp /usr/local/bin/crf_learn /usr/local/bin/crf_learn
COPY --from=crfpp /usr/local/bin/crf_test /usr/local/bin/crf_test
# copy backend
COPY ./mealie $MEALIE_HOME/mealie
COPY ./poetry.lock ./pyproject.toml $MEALIE_HOME/
COPY ./gunicorn_conf.py $MEALIE_HOME
# Alembic
COPY ./alembic $MEALIE_HOME/alembic
COPY ./alembic.ini $MEALIE_HOME/
# venv already has runtime deps installed we get a quicker install
WORKDIR $MEALIE_HOME
RUN . $VENV_PATH/bin/activate && poetry install -E pgsql --only main
WORKDIR /
# Grab CRF++ Model Release
RUN python $MEALIE_HOME/mealie/scripts/install_model.py
VOLUME [ "$MEALIE_HOME/data/" ]
ENV APP_PORT=9000
EXPOSE ${APP_PORT}
HEALTHCHECK CMD python $MEALIE_HOME/mealie/scripts/healthcheck.py || exit 1
COPY ./docker/api.entry.sh $MEALIE_HOME/mealie/run.sh
RUN chmod +x $MEALIE_HOME/mealie/run.sh
ENTRYPOINT $MEALIE_HOME/mealie/run.sh

View File

@ -1,58 +1,20 @@
version: "3.4"
services:
mealie-frontend:
container_name: mealie-frontend
image: mealie-frontend:dev
deploy:
resources:
limits:
memory: 500M
build:
context: ../
dockerfile: ./docker/frontend.Dockerfile
restart: always
volumes:
- mealie-data:/app/data/
ports:
- 9091:3000
environment:
- API_URL=http://mealie-api:9000
# =====================================
# Light Mode Config
- THEME_LIGHT_PRIMARY=#E58325
- THEME_LIGHT_ACCENT=#007A99
- THEME_LIGHT_SECONDARY=#973542
- THEME_LIGHT_SUCCESS=#43A047
- THEME_LIGHT_INFO=#1976D2
- THEME_LIGHT_WARNING=#FF6D00
- THEME_LIGHT_ERROR=#EF5350
# =====================================
# Dark Mode Config
- THEME_DARK_PRIMARY=#E58325
- THEME_DARK_ACCENT=#007A99
- THEME_DARK_SECONDARY=#973542
- THEME_DARK_SUCCESS=#43A047
- THEME_DARK_INFO=#1976D2
- THEME_DARK_WARNING=#FF6D00
- THEME_DARK_ERROR=#EF5350
mealie:
container_name: mealie-api
deploy:
resources:
limits:
memory: 1000M
container_name: mealie
image: mealie:dev
build:
context: ../
target: production
dockerfile: ./docker/api.Dockerfile
dockerfile: ./docker/Dockerfile
restart: always
volumes:
- mealie-data:/app/data/
ports:
- 9092:9000
- 9091:9000
environment:
ALLOW_SIGNUP: "false"
DB_ENGINE: sqlite # Optional: 'sqlite', 'postgres'
# =====================================
# Postgres Config
@ -78,14 +40,6 @@ services:
# SMTP_USER=
# SMTP_PASSWORD=
# postgres:
# container_name: postgres
# image: postgres
# restart: always
# environment:
# POSTGRES_PASSWORD: mealie
# POSTGRES_USER: mealie
volumes:
mealie-data:
driver: local

View File

@ -1,9 +1,9 @@
# Start Backend API
#!/bin/bash
set -e
# Get Reload Arg `run.sh reload` for dev server
ARG1=${1:-production}
# Strict Mode
# set -e
# IFS=$'\n\t'
# Get PUID/PGID
PUID=${PUID:-911}
@ -41,12 +41,8 @@ init() {
poetry run python /app/mealie/db/init_db.py
}
echo "Production"
change_user
# change_user
init
GUNICORN_PORT=${API_PORT:-9000}
# Start API

View File

@ -1,46 +0,0 @@
{
auto_https off
admin off
}
:3000 {
@apidocs path /docs /openapi.json
@static {
file
path *.ico *.css *.js *.gif *.jpg *.jpeg *.png *.svg *.woff *.woff2 *.webp
}
encode gzip zstd
# Handles Recipe Images / Assets
handle_path /api/media/recipes/* {
header @static Cache-Control max-age=31536000
root * /app/data/recipes/
file_server
}
# Handles User Images
handle_path /api/media/users/* {
header @static Cache-Control max-age=31536000
root * /app/data/users/
file_server
}
# Handle Docker Volume Validation File
handle_path /api/media/docker/* {
root * /app/data/docker-validation/
file_server
}
handle @apidocs {
uri strip_suffix /
reverse_proxy {$API_URL}
}
handle {
uri strip_suffix /
reverse_proxy http://127.0.0.1:3001
}
}

View File

@ -1,39 +0,0 @@
FROM node:16 as builder
WORKDIR /app
COPY ./frontend .
RUN yarn install \
--prefer-offline \
--frozen-lockfile \
--non-interactive \
--production=false \
# https://github.com/docker/build-push-action/issues/471
--network-timeout 1000000
RUN yarn build
RUN rm -rf node_modules && \
NODE_ENV=production yarn install \
--prefer-offline \
--pure-lockfile \
--non-interactive \
--production=true
FROM node:16-alpine
RUN apk add caddy
WORKDIR /app
# copying caddy into image
COPY --from=builder /app .
COPY ./docker/frontend.Caddyfile /app/Caddyfile
COPY ./docker/frontend.entry.sh /app/run.sh
ENV HOST 0.0.0.0
EXPOSE 3000
RUN chmod +x /app/run.sh
ENTRYPOINT /app/run.sh

View File

@ -1,7 +0,0 @@
# Production entry point for the frontend docker container
# Web Server
caddy start --config /app/Caddyfile
# Start Node Application
yarn start -p 3001

View File

@ -1,45 +0,0 @@
version: "3.4"
services:
omni-mealie:
container_name: mealie
image: mealie-omni:dev
build:
context: ../
target: production
dockerfile: ./docker/omni.Dockerfile
restart: always
volumes:
- mealie-data:/app/data/
ports:
- 9091:3000
environment:
ALLOW_SIGNUP: "false"
DB_ENGINE: sqlite # Optional: 'sqlite', 'postgres'
# =====================================
# Postgres Config
POSTGRES_USER: mealie
POSTGRES_PASSWORD: mealie
POSTGRES_SERVER: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: mealie
# =====================================
# Web Concurrency
WEB_GUNICORN: "false"
WORKERS_PER_CORE: 0.5
MAX_WORKERS: 1
WEB_CONCURRENCY: 1
# =====================================
# Email Configuration
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_FROM_NAME=Mealie
# SMTP_AUTH_STRATEGY=TLS # Options: 'TLS', 'SSL', 'NONE'
# SMTP_FROM_EMAIL=
# SMTP_USER=
# SMTP_PASSWORD=
volumes:
mealie-data:
driver: local

View File

@ -1,59 +0,0 @@
# Start Backend API
#!/bin/bash
# Strict Mode
# set -e
# IFS=$'\n\t'
# Get PUID/PGID
PUID=${PUID:-911}
PGID=${PGID:-911}
add_user() {
groupmod -o -g "$PGID" abc
usermod -o -u "$PUID" abc
}
change_user() {
# If container is started as root then create a new user and switch to it
if [ "$(id -u)" = "0" ]; then
add_user
chown -R $PUID:$PGID /app
echo "Switching to dedicated user"
exec gosu $PUID "$BASH_SOURCE" "$@"
elif [ "$(id -u)" = $PUID ]; then
echo "
User uid: $PUID
User gid: $PGID
"
fi
}
init() {
# $MEALIE_HOME directory
cd /app
# Activate our virtual environment here
. /opt/pysetup/.venv/bin/activate
# Initialize Database Prerun
poetry run python /app/mealie/db/init_db.py
}
# change_user
init
GUNICORN_PORT=${API_PORT:-9000}
# Start API
hostip=`/sbin/ip route|awk '/default/ { print $3 }'`
if [ "$WEB_GUNICORN" = 'true' ]; then
echo "Starting Gunicorn"
gunicorn mealie.app:app -b 0.0.0.0:$GUNICORN_PORT --forwarded-allow-ips=$hostip -k uvicorn.workers.UvicornWorker -c /app/gunicorn_conf.py --preload &
else
uvicorn mealie.app:app --host 0.0.0.0 --forwarded-allow-ips=$hostip --port $GUNICORN_PORT &
fi
# ------------------------------
# Start Frontend Nuxt Server
cd /app/frontend && yarn start -p 3000

View File

@ -1,29 +0,0 @@
# Frontend Configuration
## Environment Variables
### General
| Variables | Default | Description |
| --------- | :--------------------: | ------------------------- |
| API_URL | http://mealie-api:9000 | URL to proxy API requests |
### Themeing
Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x.
| Variables | Default | Description |
| --------------------- | :-----: | --------------------------- |
| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
| THEME_LIGHT_ACCENT | #007A99 | Light Theme Config Variable |
| THEME_LIGHT_SECONDARY | #973542 | Light Theme Config Variable |
| THEME_LIGHT_SUCCESS | #43A047 | Light Theme Config Variable |
| THEME_LIGHT_INFO | #1976D2 | Light Theme Config Variable |
| THEME_LIGHT_WARNING | #FF6D00 | Light Theme Config Variable |
| THEME_LIGHT_ERROR | #EF5350 | Light Theme Config Variable |
| THEME_DARK_PRIMARY | #E58325 | Dark Theme Config Variable |
| THEME_DARK_ACCENT | #007A99 | Dark Theme Config Variable |
| THEME_DARK_SECONDARY | #973542 | Dark Theme Config Variable |
| THEME_DARK_SUCCESS | #43A047 | Dark Theme Config Variable |
| THEME_DARK_INFO | #1976D2 | Dark Theme Config Variable |
| THEME_DARK_WARNING | #FF6D00 | Dark Theme Config Variable |
| THEME_DARK_ERROR | #EF5350 | Dark Theme Config Variable |

View File

@ -55,10 +55,8 @@ After you've decided setup the files it's important to set a few ENV variables t
- [x] You've configured the relevant ENV variables for your database selection in the `docker-compose.yaml` files.
- [x] You've configured the [SMTP server settings](./backend-config.md#email) (used for invitations, password resets, etc). You can setup a [google app password](https://support.google.com/accounts/answer/185833?hl=en) if you want to send email via gmail.
- [x] Verified the port mapped on the `mealie-frontend` container is an open port on your server (Default: 9925)
- [x] You've set the [`BASE_URL`](./backend-config.md#general) variable.
- [x] You've set the `DEFAULT_EMAIL` and `DEFAULT_GROUP` variable.
- [x] Make any theme changes on the frontend container. [See Frontend Config](./frontend-config.md#themeing)
## Step 3: Startup
After you've configured your database, and updated the `docker-compose.yaml` files, you can start Mealie by running the following command in the directory where you've added your `docker-compose.yaml`.
@ -79,9 +77,6 @@ You should see the containers start up without error. You should now be able to
After the startup is complete you should see a login screen. Use the default credentials above to login and navigate to `/admin/site-settings`. Here you'll find a summary of your configuration details and their respective status. Before proceeding you should validate that the configuration is correct. For any warnings or errors the page will display an error and notify you of what you need to verify.
!!! tip "Docker Volume"
Mealie uses a shared data-volume between the Backend and Frontend containers for images and assets. Ensure that this is configured correctly by using the "Docker Volume Test" section in the settings page. Running this validation will ensure that you have configured your volumes correctly. Mealie will not work correctly without this configured correctly.
## Step 5: Backup
While v1.0.0 is a great step to data-stability and security, it's not a backup. Mealie provides a full site data backup mechanism through the UI.
@ -91,6 +86,14 @@ These backups are just plain .zip files that you can download from the UI or acc
### Docker Tags
`ghcr.io/mealie-recipes/mealie:nightly`
The nightly build are the latest and greatest builds that are built directly off of every commit to the `mealie-next` branch and as such may contain bugs. These are great to help the community catch bugs before they hit the stable release or if you like living on the edge.
---
**These tags no are long updated**
`mealie:frontend-v1.0.0beta-x` **and** `mealie:api-v1.0.0beta-x`
These are the tags for the latest beta release of the frontend docker-container. These are currently considered the latest and most stable releases and the recommended way of using Mealie.
@ -98,16 +101,3 @@ These are the tags for the latest beta release of the frontend docker-container.
`mealie:frontend-nightly`**and** `mealie:api-nightly`
The nightly build are the latest and greatest builds that are built directly off of every commit to the `mealie-next` branch and as such may contain bugs. These are great to help the community catch bugs before they hit the stable release or if you like living on the edge.
### Docker Diagram
While the docker-compose file should work without modification, some users want to tailor it to their installation. This diagram shows network and volume architecture for the default setup. You can use this to help you customize your configuration.
![Docker Diagram](../../../assets/img/docker-diagram.drawio.svg)
In the diagram above there's a few crucial things to note.
1. Port 9925 is the host port, this can be anything you want. The important part is that it's mapped to the mealie-frontend container at port 3000.
2. The mealie-frontend container communicated with the mealie-api container through the INTERNAL docker network. This requires that the two containers are on the same network and that the network supports name resolution (anything but the default bridge network). The resolution URL can be specified in the docker-compose as the `API_URL` environment variable. The API_URL must match the network name of the mealie-backend container, which should be the same as the container name (e.g. a container renamed to `my-api` should have an `API_URL` set to `http://my-api:<backend port, default 9000`)
3. The mealie-data volume is mounted to BOTH the mealie-frontend and mealie-api containers. This is REQUIRED to ensure that images and assets are served up correctly. While the default configuration is a docker-volume, that same can be accomplished by using a local directory mounted to the containers.

View File

@ -4,33 +4,21 @@ PostgreSQL might be considered if you need to support many concurrent users. In
**For Environmental Variable Configuration See:**
- [Frontend Configuration](./frontend-config.md)
- [Backend Configuration](./backend-config.md)
- [Configuration](./backend-config.md)
```yaml
---
version: "3.7"
services:
mealie-frontend:
image: hkotel/mealie:frontend-v1.0.0beta-5
container_name: mealie-frontend
depends_on:
- mealie-api
environment:
# Set Frontend ENV Variables Here
- API_URL=http://mealie-api:9000 # (1)
restart: always
mealie:
image: ghcr.io/mealie-recipes/mealie:nightly
container_name: mealie
ports:
- "9925:3000" # (2)
volumes:
- mealie-data:/app/data/ # (3)
mealie-api:
image: hkotel/mealie:api-v1.0.0beta-5
container_name: mealie-api
- "9925:9000"
deploy:
resources:
limits:
memory: 1000M # (4)
memory: 1000M # (1)
depends_on:
- postgres
volumes:
@ -72,8 +60,5 @@ volumes:
<!-- Updating This? Be Sure to also update the SQLite Annotations -->
1. Whoa whoa whoa, what is this nonsense? The API_URL is the URL the frontend container uses to proxy api requests to the backend server. In this example, the name `mealie-api` resolves to the `mealie-api` container which runs the API server on port 9000. This allows you to access the API without exposing an additional port on the host.
<br/> <br/> **Note** that both containers must be on the same docker-network for this to work.
2. To access the mealie interface you only need to expose port 3000 on the mealie-frontend container. Here we expose port 9925 on the host, feel free to change this to any port you like.
3. Mounting the data directory to the frontend is now required to access the images/assets directory. This can be mounted read-only. Internally the frontend containers runs a Caddy proxy server that serves the assets requested to reduce load on the backend API.
4. Setting an explicit memory limit is recommended. Python can pre-allocate larger amounts of memory than is necessary if you have a machine with a lot of RAM. This can cause the container to idle at a high memory usage. Setting a memory limit will improve idle performance.
1. To access the mealie interface you only need to expose port 9000 on the mealie-frontend container. Here we expose port 9925 on the host, feel free to change this to any port you like.
2. Setting an explicit memory limit is recommended. Python can pre-allocate larger amounts of memory than is necessary if you have a machine with a lot of RAM. This can cause the container to idle at a high memory usage. Setting a memory limit will improve idle performance.

View File

@ -1,37 +0,0 @@
# Using the Omni Image
Since [#1948](https://github.com/hay-kot/mealie/pull/1948) we've started publishing an experimental image that merges both the frontend and backend services into a single container image. This image is currently in an experimental state, and should be used with caution. Continued support for this image will be based on user feedback and demand, if you're using this image please let us know how it's working for you. The single container comes with SQLite, and can be used with PostgreSQL by adding a postgres container to the docker-compose file (see the [PostgreSQL install](./postgres.md) for a snippet).
- [Feedback Discussion](https://github.com/hay-kot/mealie/discussions/1949)
**For Environmental Variable Configuration See:**
Note that frontend and backend configurations are both applied to the same container. The container exposes the port 9000 for the API and port 3000 for the frontend.
- [Frontend Configuration](./frontend-config.md)
- [Backend Configuration](./backend-config.md)
```yaml
---
version: "3.7"
services:
mealie-omni:
image: hkotel/mealie:omni-nightly
container_name: mealie
ports:
- "3000:3000"
- "9000:9000"
volumes:
- mealie-data:/app/data/
environment:
- ALLOW_SIGNUP=true
- PUID=1000
- PGID=1000
- TZ=America/Anchorage
- BASE_URL=https://mealie.yourdomain.com
restart: always
volumes:
mealie-data:
driver: local
```

View File

@ -4,31 +4,21 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
**For Environmental Variable Configuration See:**
- [Frontend Configuration](./frontend-config.md)
- [Backend Configuration](./backend-config.md)
- [Configuration](./backend-config.md)
```yaml
---
version: "3.7"
services:
mealie-frontend:
image: hkotel/mealie:frontend-v1.0.0beta-5
container_name: mealie-frontend
environment:
# Set Frontend ENV Variables Here
- API_URL=http://mealie-api:9000 # (1)
restart: always
ports:
- "9925:3000" # (2)
volumes:
- mealie-data:/app/data/ # (3)
mealie-api:
image: hkotel/mealie:api-v1.0.0beta-5
image: ghcr.io/mealie-recipes/mealie:nightly
container_name: mealie-api
ports:
"9925:9000" # (1)
deploy:
resources:
limits:
memory: 1000M # (4)
memory: 1000M # (2)
volumes:
- mealie-data:/app/data/
environment:
@ -49,8 +39,5 @@ volumes:
<!-- Updating This? Be Sure to also update the Postgres Annotations -->
1. Whoa whoa whoa, what is this nonsense? The API_URL is the URL the frontend container uses to proxy api requests to the backend server. In this example, the name `mealie-api` resolves to the `mealie-api` container which runs the API server on port 9000. This allows you to access the API without exposing an additional port on the host.
<br/> <br/> **Note** that both containers must be on the same docker-network for this to work.
2. To access the mealie interface you only need to expose port 3000 on the mealie-frontend container. Here we expose port 9925 on the host, feel free to change this to any port you like.
3. Mounting the data directory to the frontend is now required to access the images/assets directory. This can be mounted read-only. Internally the frontend containers runs a Caddy proxy server that serves the assets requested to reduce load on the backend API.
4. Setting an explicit memory limit is recommended. Python can pre-allocate larger amounts of memory than is necessary if you have a machine with a lot of RAM. This can cause the container to idle at a high memory usage. Setting a memory limit will improve idle performance.
1. To access the mealie interface you only need to expose port 9000 on the mealie-frontend container. Here we expose port 9925 on the host, feel free to change this to any port you like.
2. Setting an explicit memory limit is recommended. Python can pre-allocate larger amounts of memory than is necessary if you have a machine with a lot of RAM. This can cause the container to idle at a high memory usage. Setting a memory limit will improve idle performance.

File diff suppressed because one or more lines are too long

View File

@ -315,8 +315,8 @@
Groups
</h2>
<p>
Sort users into groups to share recipes and mealplans with the whole family while keeping
your cooking club separate.
Full multi-user support with groups for sharing recipes and meal plans
with family.
</p>
</div>
<div class="feature-item">

View File

@ -71,8 +71,6 @@ nav:
- Installation Checklist: "documentation/getting-started/installation/installation-checklist.md"
- SQLite (Recommended): "documentation/getting-started/installation/sqlite.md"
- PostgreSQL: "documentation/getting-started/installation/postgres.md"
- Single Container (Experimental): "documentation/getting-started/installation/single-container.md"
- Frontend Configuration: "documentation/getting-started/installation/frontend-config.md"
- Backend Configuration: "documentation/getting-started/installation/backend-config.md"
- Usage:
- Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md"

View File

@ -1,7 +1,13 @@
<template>
<v-container :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
<RecipePageHeader :recipe="recipe" :recipe-scale="scale" :landscape="landscape" @save="saveRecipe" @delete="deleteRecipe" />
<RecipePageHeader
:recipe="recipe"
:recipe-scale="scale"
:landscape="landscape"
@save="saveRecipe"
@delete="deleteRecipe"
/>
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" />
<v-card-text v-else>
<!--
@ -100,7 +106,6 @@ import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContaine
import { EditorMode, PageMode, usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
import { useRecipeMeta } from "~/composables/recipes";
import { useRouteQuery } from "~/composables/use-router";
import { useUserApi } from "~/composables/api";
import { uuid4, deepCopy } from "~/composables/use-utils";
@ -275,9 +280,6 @@ export default defineComponent({
/** =============================================================
* Meta Tags
*/
const { recipeMeta } = useRecipeMeta();
useMeta(recipeMeta(ref(props.recipe)));
const { user } = usePageUser();
return {

View File

@ -3,4 +3,3 @@ export { useRecipe } from "./use-recipe";
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
export { useTools } from "./use-recipe-tools";
export { useRecipeMeta } from "./use-recipe-meta";

View File

@ -1,58 +0,0 @@
import { Ref } from "@nuxtjs/composition-api";
import { useStaticRoutes } from "~/composables/api";
import { Recipe } from "~/lib/api/types/recipe";
export interface RecipeMeta {
title?: string;
mainImage?: string;
meta: Array<any>;
__dangerouslyDisableSanitizers: Array<string>;
script: Array<any>;
}
export const useRecipeMeta = () => {
const { recipeImage } = useStaticRoutes();
function recipeMeta(recipe: Ref<Recipe | null>): RecipeMeta {
const imageURL = recipeImage(recipe?.value?.id ?? "");
return {
title: recipe?.value?.name,
mainImage: imageURL,
meta: [
{ hid: "og:title", property: "og:title", content: recipe?.value?.name || "Recipe" },
{
hid: "og:description",
property: "og:description",
content: recipe?.value?.description ?? "",
},
{
hid: "og:image",
property: "og:image",
content: imageURL,
},
{
hid: "twitter:title",
property: "twitter:title",
content: recipe?.value?.name ?? "",
},
{
hid: "twitter:desc",
property: "twitter:description",
content: recipe?.value?.description ?? "",
},
{ hid: "t-type", name: "twitter:card", content: "summary_large_image" },
],
__dangerouslyDisableSanitizers: ["script"],
script: [
{
innerHTML: JSON.stringify({
"@context": "http://schema.org",
"@type": "Recipe",
...recipe.value,
}),
type: "application/ld+json",
},
],
};
}
return { recipeMeta };
};

View File

@ -1,5 +1,6 @@
export default {
// Global page headers: https://go.nuxtjs.dev/config-head
target: "static",
head: {
title: "Mealie",
meta: [

View File

@ -7,10 +7,9 @@
</template>
<script lang="ts">
import { defineComponent, ref, useAsync, useMeta, useRoute, useRouter } from "@nuxtjs/composition-api";
import { defineComponent, useAsync, useMeta, useRoute, useRouter } from "@nuxtjs/composition-api";
import RecipePage from "~/components/Domain/Recipe/RecipePage/RecipePage.vue";
import { usePublicExploreApi } from "~/composables/api/api-client";
import { useRecipeMeta } from "~/composables/recipes";
export default defineComponent({
components: { RecipePage },
@ -22,8 +21,7 @@ export default defineComponent({
const recipeSlug = route.value.params.recipeSlug;
const api = usePublicExploreApi(groupSlug);
const { meta, title } = useMeta();
const { recipeMeta } = useRecipeMeta();
const { title } = useMeta();
const recipe = useAsync(async () => {
const { data, error } = await api.explore.recipes.getOne(recipeSlug);
@ -35,8 +33,6 @@ export default defineComponent({
if (data) {
title.value = data?.name || "";
const metaObj = recipeMeta(ref(data));
meta.value = metaObj.meta;
}
return data;

View File

@ -10,7 +10,6 @@
import { defineComponent, ref, useAsync, useMeta, useRoute, useRouter } from "@nuxtjs/composition-api";
import RecipePage from "~/components/Domain/Recipe/RecipePage/RecipePage.vue";
import { usePublicApi } from "~/composables/api/api-client";
import { useRecipeMeta } from "~/composables/recipes";
export default defineComponent({
components: { RecipePage },
@ -21,8 +20,7 @@ export default defineComponent({
const recipeId = route.value.params.id;
const api = usePublicApi();
const { meta, title } = useMeta();
const { recipeMeta } = useRecipeMeta();
const { title } = useMeta();
const recipe = useAsync(async () => {
const { data, error } = await api.shared.getShared(recipeId);
@ -34,8 +32,6 @@ export default defineComponent({
if (data) {
title.value = data?.name || "";
const metaObj = recipeMeta(ref(data));
meta.value = metaObj.meta;
}
return data;

View File

@ -122,9 +122,6 @@ frontend-lint: ## 🧺 Run yarn lint
# -----------------------------------------------------------------------------
# Docker makefile
docker/omni: ## 🐳 Build and start the omni style container
cd docker && docker-compose -f omni.docker-compose.yml -p mealie-omni up --build
docker/prod: ## 🐳 Build and Start Docker Production Stack
cd docker && docker-compose -f docker-compose.yml -p mealie up --build

View File

@ -6,7 +6,7 @@ from fastapi.routing import APIRoute
from mealie.core.config import get_app_settings
from mealie.core.root_logger import get_logger
from mealie.core.settings.static import APP_VERSION
from mealie.routes import router, utility_routes
from mealie.routes import router, spa, utility_routes
from mealie.routes.handlers import register_debug_handler
from mealie.routes.media import media_router
from mealie.services.scheduler import SchedulerRegistry, SchedulerService, tasks
@ -77,6 +77,9 @@ def api_routers():
app.include_router(media_router)
app.include_router(utility_routes.router)
if settings.PRODUCTION and not settings.TESTING:
spa.mount_spa(app)
api_routers()

View File

@ -5,7 +5,7 @@ from pathlib import Path
from uuid import uuid4
import fastapi
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm.session import Session
@ -64,12 +64,29 @@ async def get_public_group(group_slug: str = fastapi.Path(...), session=Depends(
return group
async def get_current_user(token: str = Depends(oauth2_scheme), session=Depends(generate_session)) -> PrivateUser:
async def try_get_current_user(
request: Request,
token: str = Depends(oauth2_scheme_soft_fail),
session=Depends(generate_session),
) -> PrivateUser | None:
try:
return await get_current_user(request, token, session)
except Exception:
return None
async def get_current_user(
request: Request, token: str = Depends(oauth2_scheme_soft_fail), session=Depends(generate_session)
) -> PrivateUser:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if token is None and "mealie.access_token" in request.cookies:
# Try extract from cookie
token = request.cookies.get("mealie.access_token", "")
try:
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")

View File

@ -27,6 +27,9 @@ class AppSettings(BaseSettings):
BASE_URL: str = "http://localhost:8080"
"""trailing slashes are trimmed (ex. `http://localhost:8080/` becomes ``http://localhost:8080`)"""
STATIC_FILES: str = ""
"""path to static files directory (ex. `mealie/dist`)"""
IS_DEMO: bool = False
API_PORT: int = 9000
API_DOCS: bool = True

View File

@ -1,6 +1,6 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi import APIRouter, Depends, Form, Request, Response, status
from fastapi.exceptions import HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
@ -49,7 +49,12 @@ class MealieAuthToken(BaseModel):
@public_router.post("/token")
def get_token(request: Request, data: CustomOAuth2Form = Depends(), session: Session = Depends(generate_session)):
def get_token(
request: Request,
response: Response,
data: CustomOAuth2Form = Depends(),
session: Session = Depends(generate_session),
):
email = data.username
password = data.password
if "x-forwarded-for" in request.headers:
@ -73,6 +78,14 @@ def get_token(request: Request, data: CustomOAuth2Form = Depends(), session: Ses
duration = timedelta(days=14) if data.remember_me else None
access_token = security.create_access_token(dict(sub=str(user.id)), duration) # type: ignore
response.set_cookie(
key="mealie.access_token",
value=access_token,
httponly=True,
max_age=duration.seconds if duration else None,
)
return MealieAuthToken.respond(access_token)

View File

@ -0,0 +1,160 @@
import json
import pathlib
from fastapi import Depends, FastAPI, Response
from fastapi.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm.session import Session
from starlette.exceptions import HTTPException
from text_unidecode import os
from mealie.core.config import get_app_settings
from mealie.core.dependencies.dependencies import try_get_current_user
from mealie.db.db_setup import generate_session
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.user.user import PrivateUser
class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
try:
return await super().get_response(path, scope)
except HTTPException as ex:
if ex.status_code == 404:
return await super().get_response("index.html", scope)
else:
raise ex
except Exception as e:
raise e
__app_settings = get_app_settings()
__contents = ""
def content_with_meta(recipe: Recipe) -> str:
# Inject meta tags
recipe_url = f"{__app_settings.BASE_URL}/recipe/{recipe.slug}"
image_url = f"{__app_settings.BASE_URL}/api/media/recipes/{recipe.id}/images/original.webp?version={recipe.image}"
ingredients: list[str] = []
if recipe.settings.disable_amount: # type: ignore
ingredients = [i.note for i in recipe.recipe_ingredient if i.note]
else:
for ing in recipe.recipe_ingredient:
s = ""
if ing.quantity:
s += f"{ing.quantity} "
if ing.unit:
s += f"{ing.unit.name} "
if ing.food:
s += f"{ing.food.name} "
if ing.note:
s += f"{ing.note}"
ingredients.append(s)
nutrition: dict[str, str | None] = {}
if recipe.nutrition:
nutrition["calories"] = recipe.nutrition.calories
nutrition["fatContent"] = recipe.nutrition.fat_content
nutrition["fiberContent"] = recipe.nutrition.fiber_content
nutrition["proteinContent"] = recipe.nutrition.protein_content
nutrition["carbohydrateContent"] = recipe.nutrition.carbohydrate_content
nutrition["sodiumContent"] = recipe.nutrition.sodium_content
nutrition["sugarContent"] = recipe.nutrition.sugar_content
as_schema_org = {
"@context": "https://schema.org",
"@type": "Recipe",
"name": recipe.name,
"description": recipe.description,
"image": [image_url],
"datePublished": recipe.created_at,
"prepTime": recipe.prep_time,
"cookTime": recipe.cook_time,
"totalTime": recipe.total_time,
"recipeYield": recipe.recipe_yield,
"recipeIngredient": ingredients,
"recipeInstructions": [i.text for i in recipe.recipe_instructions] if recipe.recipe_instructions else [],
"recipeCategory": [c.name for c in recipe.recipe_category] if recipe.recipe_category else [],
"keywords": [t.name for t in recipe.tags] if recipe.tags else [],
"nutrition": nutrition,
}
tags = [
f'<meta property="og:title" content="{recipe.name}" />',
f'<meta property="og:description" content="{recipe.description}" />',
f'<meta property="og:image" content="{image_url}" />',
f'<meta property="og:url" content="{recipe_url}" />',
'<meta name="twitter:card" content="summary_large_image" />',
f'<meta name="twitter:title" content="{recipe.name}" />',
f'<meta name="twitter:description" content="{recipe.description}" />',
f'<meta name="twitter:image" content="{image_url}" />',
f'<meta name="twitter:url" content="{recipe_url}" />',
f"""<script type="application/ld+json">{json.dumps(jsonable_encoder(as_schema_org))}</script>""",
]
return __contents.replace("</head>", "\n".join(tags) + "\n</head>", 1)
def response_404():
return Response(__contents, media_type="text/html", status_code=404)
def serve_recipe_with_meta_public(
group_slug: str,
recipe_slug: str,
session: Session = Depends(generate_session),
):
try:
repos = AllRepositories(session)
group = repos.groups.get_by_slug_or_id(group_slug)
if not group or group.preferences.private_group: # type: ignore
return response_404()
recipe = repos.recipes.by_group(group.id).get_one(recipe_slug)
if not recipe or not recipe.settings.public: # type: ignore
return response_404()
# Inject meta tags
return Response(content_with_meta(recipe), media_type="text/html")
except Exception:
return response_404()
async def serve_recipe_with_meta(
slug: str,
user: PrivateUser = Depends(try_get_current_user),
session: Session = Depends(generate_session),
):
if not user:
return Response(__contents, media_type="text/html", status_code=401)
try:
repos = AllRepositories(session)
recipe = repos.recipes.by_group(user.group_id).get_one(slug, "slug")
if recipe is None:
return response_404()
# Serve contents as HTML
return Response(content_with_meta(recipe), media_type="text/html")
except Exception:
return response_404()
def mount_spa(app: FastAPI):
if not os.path.exists(__app_settings.STATIC_FILES):
return
global __contents
__contents = pathlib.Path(__app_settings.STATIC_FILES).joinpath("index.html").read_text()
app.get("/recipe/{slug}")(serve_recipe_with_meta)
app.get("/explore/recipes/{group_slug}/{recipe_slug}")(serve_recipe_with_meta_public)
app.mount("/", SPAStaticFiles(directory=__app_settings.STATIC_FILES, html=True), name="spa")