-
Notifications
You must be signed in to change notification settings - Fork 0
feat(infra): add dockerfile, compose setup and migrate containerized #2
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
feat(infra): add dockerfile, compose setup and migrate containerized #2
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR migrates the deployment infrastructure from a traditional systemd-based setup to a containerized Docker Compose deployment. The changes modernize the infrastructure by packaging the Django application in Docker, using Caddy as a containerized reverse proxy, and simplifying the deployment process through Docker Compose orchestration.
Key changes include:
- Added Dockerfile with multi-stage build for the Django application running on Gunicorn
- Created docker-compose.yml defining app and Caddy services with shared static volume
- Updated Ansible playbook to deploy via Docker Compose instead of systemd service
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| Dockerfile | Multi-stage build configuration for Python 3.13 Django app with Gunicorn, healthcheck, and non-root user |
| docker-compose.yml | Service definitions for Django app and Caddy reverse proxy with volume mounts for SQLite DB and static files |
| config/settings.py | Added SECURE_PROXY_SSL_HEADER for HTTPS handling behind reverse proxy |
| ansible/deploy.yml | Replaced systemd/venv deployment with Docker installation and Docker Compose orchestration |
| ansible/env.j2 | Updated environment template with Docker/Django configuration variables |
| ansible/Caddyfile | Updated to use containerized paths and environment variable for domain |
| Makefile | Added Docker image build and push targets |
| .env.example | Updated with all required environment variables for containerized deployment |
| .dockerignore | Standard Python/Django exclusions for Docker build context |
| ansible/ansible.cfg | Added SSH connection optimization settings |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
docker-compose.yml
Outdated
| env_file: | ||
| - .env | ||
| volumes: | ||
| - ./db.sqlite3:/app/db.sqlite3 |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mounting SQLite database file directly from host can cause database corruption issues in containerized environments due to file locking problems. Consider using a Docker volume instead: - db-data:/app with a named volume, or migrate to a proper client-server database like PostgreSQL for production use.
ansible/deploy.yml
Outdated
| owner: deploy | ||
| group: www-data | ||
| mode: '0644' | ||
| mode: '0666' |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Setting database file permissions to 0666 (world-writable) is a security risk. This allows any user on the system to read and modify the database. If the container requires write access, consider using a named volume with appropriate Docker volume permissions or ensure the container user ID matches the host user ID.
| - name: add Docker APT GPG key (Ubuntu) | ||
| ansible.builtin.shell: "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg" | ||
| args: | ||
| creates: "{{ venv }}/bin/activate" | ||
| become_user: deploy | ||
| creates: /etc/apt/keyrings/docker.gpg |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Using shell with piped commands is less idiomatic than Ansible's dedicated modules. Consider using ansible.builtin.get_url to download the GPG key file and then ansible.builtin.command to dearmor it separately, which provides better error handling and idempotency.
| - name: bring up stack via Docker Compose (force recreate) | ||
| ansible.builtin.command: docker compose up -d --force-recreate |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using --force-recreate on every deployment will cause unnecessary downtime. Consider removing this flag and letting Docker Compose handle updates intelligently based on image/configuration changes, or only force recreate when truly needed.
| - name: bring up stack via Docker Compose (force recreate) | |
| ansible.builtin.command: docker compose up -d --force-recreate | |
| - name: bring up stack via Docker Compose | |
| ansible.builtin.command: docker compose up -d |
| ca-certificates curl gosu \ | ||
| && rm -rf /var/lib/apt/lists/* | ||
|
|
||
| RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin app \ |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Hardcoded UID 10001 may conflict with existing UIDs on the host system when mounting volumes. Consider making the UID configurable via build argument or document why this specific UID was chosen.
| HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ | ||
| CMD curl -fsS "http://127.0.0.1:${PORT}/" || exit 1 | ||
|
|
||
| CMD ["/bin/sh", "-c", "exec gunicorn config.wsgi:application --bind 0.0.0.0:${PORT:-8000} --workers ${GUNICORN_WORKERS:-3} --access-logfile - --error-logfile -"] |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The CMD uses shell expansion for environment variables which is correct, but this could be simplified using the default environment values already set on lines 30-31. Consider referencing $GUNICORN_BIND directly or remove the defaults here to avoid duplication with ENV declarations.
| CMD ["/bin/sh", "-c", "exec gunicorn config.wsgi:application --bind 0.0.0.0:${PORT:-8000} --workers ${GUNICORN_WORKERS:-3} --access-logfile - --error-logfile -"] | |
| CMD ["/bin/sh", "-c", "exec gunicorn config.wsgi:application --bind 0.0.0.0:$PORT --workers $GUNICORN_WORKERS --access-logfile - --error-logfile -"] |
7ad80fa to
8b30105
Compare
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 15 out of 17 changed files in this pull request and generated 14 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| GUNICORN_BIND=0.0.0.0:8000 \ | ||
| GUNICORN_MAX_REQUESTS=1000 \ | ||
| GUNICORN_MAX_REQUESTS_JITTER=100 \ | ||
| PORT=8000 |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The environment variables GUNICORN_BIND and GUNICORN_MAX_REQUESTS are defined but never used in the CMD directive. The CMD at line 48 only uses PORT and GUNICORN_WORKERS. Either use these variables in the command or remove them:
CMD ["/bin/sh", "-c", "exec gunicorn config.wsgi:application --bind ${GUNICORN_BIND:-0.0.0.0:8000} --workers ${GUNICORN_WORKERS:-3} --max-requests ${GUNICORN_MAX_REQUESTS:-1000} --max-requests-jitter ${GUNICORN_MAX_REQUESTS_JITTER:-100} --access-logfile - --error-logfile -"]| - name: run database migrations | ||
| ansible.builtin.command: docker compose exec -T app python manage.py migrate --noinput | ||
| args: | ||
| chdir: "{{ dest }}" | ||
| become_user: deploy | ||
|
|
||
| - name: restart service | ||
| ansible.builtin.systemd: | ||
| name: idontneedit | ||
| state: restarted | ||
|
|
||
| - name: install Caddy | ||
| apt: | ||
| name: caddy | ||
| state: present | ||
| update_cache: yes | ||
|
|
||
| - name: install Caddyfile | ||
| copy: | ||
| src: Caddyfile | ||
| dest: /etc/caddy/Caddyfile | ||
| owner: root | ||
| group: root | ||
| mode: '0644' | ||
| backup: yes | ||
|
|
||
| - name: enable and start caddy | ||
| ansible.builtin.systemd: | ||
| name: caddy | ||
| enabled: yes | ||
| state: restarted | ||
|
|
||
| - name: Run deployment checks | ||
| ansible.builtin.command: "{{ venv }}/bin/python manage.py check --deploy --fail-level=WARNING" | ||
| - name: collect static files into shared volume | ||
| ansible.builtin.command: docker compose exec -T app python manage.py collectstatic --noinput | ||
| args: | ||
| chdir: "{{ dest }}" | ||
| become_user: deploy |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Running docker compose exec immediately after docker compose up -d may fail if the containers are not fully ready. While the postgres service has a healthcheck, the app service only has service_started condition from caddy's perspective. The migrations and collectstatic commands should either:
- Add a retry mechanism
- Wait for the app container to be healthy (it has a healthcheck defined)
- Add a delay or explicit readiness check
Consider using docker compose exec with retry logic or checking container health first.
| @@ -1 +1,13 @@ | |||
| DJANGO_DEBUG=True | |||
| DJANGO_SECRET_KEY=change-me | |||
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The default value for DJANGO_DEBUG is inconsistent between files. In .env.example it's false (string), but django-environ expects a boolean. While django-environ can parse string values like "false", "False", "0", etc., using "true" or "false" strings is clearer. However, in config/settings.py line 23, env.bool() is used which properly handles this. Consider documenting that this should be "true" or "false" (lowercase) for consistency.
| DJANGO_SECRET_KEY=change-me | |
| DJANGO_SECRET_KEY=change-me | |
| # Set DJANGO_DEBUG to "true" or "false" (lowercase) for clarity. |
ansible/deploy.yml
Outdated
| - name: ensure database file exists with permissive permissions (for container write) | ||
| ansible.builtin.file: | ||
| path: "{{ dest }}/db.sqlite3" | ||
| owner: deploy | ||
| group: www-data | ||
| mode: '0644' | ||
| mode: '0666' | ||
| state: touch | ||
| modification_time: preserve | ||
| access_time: preserve |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Creating a SQLite database file in production when using PostgreSQL is unnecessary and confusing. The deployment is containerized with PostgreSQL, so this SQLite file creation should be removed. According to the settings, production uses PostgreSQL (lines 94-105 in config/settings.py), making this SQLite file obsolete.
| - name: add Docker APT repository (Ubuntu) | ||
| ansible.builtin.apt_repository: | ||
| repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_lsb.codename }} stable" |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Docker repository setup assumes the system is Ubuntu and uses a hardcoded arch=amd64. This will fail on ARM-based systems or non-Ubuntu Debian distributions. Consider detecting the architecture dynamically:
- name: detect system architecture
ansible.builtin.command: dpkg --print-architecture
register: system_arch
changed_when: false
- name: add Docker APT repository
ansible.builtin.apt_repository:
repo: "deb [arch={{ system_arch.stdout }} signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_lsb.codename }} stable"| - name: add Docker APT repository (Ubuntu) | |
| ansible.builtin.apt_repository: | |
| repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_lsb.codename }} stable" | |
| - name: detect system architecture | |
| ansible.builtin.command: dpkg --print-architecture | |
| register: system_arch | |
| changed_when: false | |
| - name: add Docker APT repository (Ubuntu) | |
| ansible.builtin.apt_repository: | |
| repo: "deb [arch={{ system_arch.stdout }} signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_lsb.codename }} stable" |
|
|
||
| # Only read .env file in development | ||
| DEBUG = env.bool("DJANGO_DEBUG", default=False) | ||
| if DEBUG: | ||
| environ.Env.read_env(BASE_DIR / ".env") |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The .env file reading logic has a circular dependency issue. DEBUG is read from the environment at line 23, but the .env file is only loaded at line 25 if DEBUG is True. This means in production, if DJANGO_DEBUG is not already set in the environment before Django starts, it will default to False and never read the .env file.
The correct approach would be to either:
- Always read the
.envfile first, then check DEBUG, OR - Remove conditional reading entirely and rely on environment variables being set externally in production
Consider changing to:
env = environ.Env(DJANGO_DEBUG=(bool, False))
# Read .env file if it exists (optional for development)
environ.Env.read_env(BASE_DIR / ".env", overwrite=False)
DEBUG = env.bool("DJANGO_DEBUG", default=False)| # Only read .env file in development | |
| DEBUG = env.bool("DJANGO_DEBUG", default=False) | |
| if DEBUG: | |
| environ.Env.read_env(BASE_DIR / ".env") | |
| # Read .env file if it exists (optional for development) | |
| environ.Env.read_env(BASE_DIR / ".env", overwrite=False) | |
| DEBUG = env.bool("DJANGO_DEBUG", default=False) |
config/settings.py
Outdated
| "ENGINE": "django.db.backends.postgresql", | ||
| "NAME": env("POSTGRES_DB", default="idontneedit"), | ||
| "USER": env("POSTGRES_USER", default="idontneedit"), | ||
| "PASSWORD": env("POSTGRES_PASSWORD"), |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In production mode, POSTGRES_PASSWORD is required but has no default value. If this environment variable is not set, the application will crash when accessing the database configuration. Consider adding a default value or implementing explicit validation that raises a clear error message early in the application startup.
Example:
"PASSWORD": env("POSTGRES_PASSWORD", default=""), # Or raise ImproperlyConfigured if empty62d51fd to
bb8e4a0
Compare
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
… of github.com:histrio/idontneedit into feat/infra-add-dockerfile-compose-containerized-deploy
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 16 out of 18 changed files in this pull request and generated 19 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| IMAGE_TAG?=$(shell git rev-parse --short HEAD) | ||
| LATEST_TAG?=latest | ||
|
|
||
| .PHONY: image.build | ||
| image.build: | ||
| @$(DOCKER) build -t $(IMAGE_REPO):$(IMAGE_TAG) -t $(IMAGE_REPO):$(LATEST_TAG) . | ||
|
|
||
| .PHONY: image.push | ||
| image.push: | ||
| @$(DOCKER) push $(IMAGE_REPO):$(IMAGE_TAG) | ||
| @$(DOCKER) push $(IMAGE_REPO):$(LATEST_TAG) |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The image.build and image.push targets use IMAGE_TAG?=$(shell git rev-parse --short HEAD) which will fail if the directory is not a git repository or git is not installed. Consider adding error handling or documenting that these targets require git.
| IMAGE_TAG?=$(shell git rev-parse --short HEAD) | |
| LATEST_TAG?=latest | |
| .PHONY: image.build | |
| image.build: | |
| @$(DOCKER) build -t $(IMAGE_REPO):$(IMAGE_TAG) -t $(IMAGE_REPO):$(LATEST_TAG) . | |
| .PHONY: image.push | |
| image.push: | |
| @$(DOCKER) push $(IMAGE_REPO):$(IMAGE_TAG) | |
| @$(DOCKER) push $(IMAGE_REPO):$(LATEST_TAG) | |
| # NOTE: The image.build and image.push targets require git to be installed and the directory to be a git repository. | |
| IMAGE_TAG?=$(shell git rev-parse --short HEAD) | |
| LATEST_TAG?=latest | |
| .PHONY: image.build | |
| image.build: | |
| @if ! command -v git >/dev/null 2>&1; then \ | |
| echo "Error: git is not installed. Cannot determine IMAGE_TAG."; exit 1; \ | |
| fi; \ | |
| if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then \ | |
| echo "Error: Not a git repository. Cannot determine IMAGE_TAG."; exit 1; \ | |
| fi; \ | |
| $(DOCKER) build -t $(IMAGE_REPO):$(IMAGE_TAG) -t $(IMAGE_REPO):$(LATEST_TAG) . | |
| .PHONY: image.push | |
| image.push: | |
| @if ! command -v git >/dev/null 2>&1; then \ | |
| echo "Error: git is not installed. Cannot determine IMAGE_TAG."; exit 1; \ | |
| fi; \ | |
| if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then \ | |
| echo "Error: Not a git repository. Cannot determine IMAGE_TAG."; exit 1; \ | |
| fi; \ | |
| $(DOCKER) push $(IMAGE_REPO):$(IMAGE_TAG) | |
| $(DOCKER) push $(IMAGE_REPO):$(LATEST_TAG) |
| - name: add deploy user to docker group | ||
| ansible.builtin.user: | ||
| name: deploy | ||
| groups: docker | ||
| append: yes | ||
|
|
||
| - name: start and enable docker service | ||
| ansible.builtin.systemd: | ||
| name: docker | ||
| enabled: yes | ||
| state: started | ||
|
|
||
| - name: pull latest images via Docker Compose | ||
| ansible.builtin.command: docker compose pull | ||
| args: | ||
| chdir: "{{ dest }}" | ||
| become_user: deploy |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The deploy user is added to the docker group (line 177), but Docker needs to be restarted or the user needs to log out/in for group membership to take effect. The subsequent docker commands (lines 187, 193) run as the deploy user might fail on first deployment if the user's group membership hasn't been refreshed. Consider adding newgrp docker or using sg docker -c for the docker commands.
| @@ -1,15 +1,18 @@ | |||
| idontneedit.org.ru { | |||
| {$SITE_DOMAIN} { | |||
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Caddyfile references environment variable {$SITE_DOMAIN}, but this variable is not explicitly set in docker-compose.yml. While it's defined in the env.j2 template and .env.example, the Caddy container needs this variable to be available. Ensure that docker-compose.yml passes this environment variable to the caddy service, either through env_file or explicitly in the environment section.
| ca-certificates curl \ | ||
| && rm -rf /var/lib/apt/lists/* | ||
|
|
||
| RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin app \ |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The user is created with shell /usr/sbin/nologin, which is appropriate for security. However, this will prevent interactive debugging if needed. Consider documenting this choice or using /bin/false which is more commonly used for this purpose.
| RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin app \ | |
| RUN useradd --uid 10001 --create-home --shell /bin/false app \ |
| env = environ.Env( | ||
| DJANGO_PRODUCTION=(bool, False), | ||
| DJANGO_DEBUG=(bool, False), | ||
| DJANGO_SECRET_KEY=(str, "django-insecure-please-change-me-in-production"), |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The default value for DJANGO_SECRET_KEY is "django-insecure-please-change-me-in-production" which includes the word "insecure". Django 6.0rc1 might have checks that warn about or reject secret keys containing "insecure". While this is intended as a placeholder, it could cause confusion. Consider using a different placeholder value or adding validation to ensure this default is never used in production.
| restart: unless-stopped | ||
|
|
||
| app: | ||
| image: ${IMAGE_NAME:-histrio/idontneedit:latest} |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The pull_policy: always will always attempt to pull the image even if it exists locally. This is correct for production deployments to ensure the latest image is used, but it will cause slower startups. Document this behavior or consider using pull_policy: missing for development environments via docker-compose.override.yml.
| image: ${IMAGE_NAME:-histrio/idontneedit:latest} | |
| image: ${IMAGE_NAME:-histrio/idontneedit:latest} | |
| # pull_policy: always ensures the latest image is always used (recommended for production). | |
| # For development, this may cause slower startups. You can override this in docker-compose.override.yml: | |
| # app: | |
| # pull_policy: missing |
| DJANGO_STATIC_ROOT=(str, "/app/static/"), | ||
| ) | ||
|
|
||
| PRODUCTION = env.bool("DJANGO_PRODUCTION") |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The .env file is only read when DJANGO_PRODUCTION is False. However, in the Dockerfile and docker-compose.yml, environment variables are passed directly and DJANGO_PRODUCTION=true is set in production. This means the .env file reading logic in lines 32-33 will never execute in production (which is correct), but it also means that in development with docker-compose, if DJANGO_PRODUCTION is not explicitly set to false or is missing, the .env file won't be read. Consider explicitly documenting this behavior or ensuring the development docker-compose.override.yml sets DJANGO_PRODUCTION=false if you want to use .env files in containerized development.
| PRODUCTION = env.bool("DJANGO_PRODUCTION") | |
| PRODUCTION = env.bool("DJANGO_PRODUCTION") | |
| # The .env file is only read when DJANGO_PRODUCTION is False. | |
| # In containerized development (e.g., Docker Compose), ensure DJANGO_PRODUCTION=false is set | |
| # if you want to use .env files for configuration. Otherwise, environment variables are used. |
| - name: pull latest images via Docker Compose | ||
| ansible.builtin.command: docker compose pull | ||
| args: | ||
| chdir: "{{ dest }}" | ||
| become_user: deploy |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docker compose pull command is executed before bringing up the stack. However, if the app service is configured to build locally (via docker-compose.override.yml), the pull will fail or pull an outdated image. Consider adding a condition to check if local build is being used, or document that this playbook is only for pulling pre-built images from the registry.
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
…deploy