Compare commits

...

21 Commits

Author SHA1 Message Date
caf482e9f4 fix(scripts): migrate to rclone from aws-cli and add cleanup actions
Some checks failed
Deployment CI/CD / validate (push) Has been cancelled
Deployment CI/CD / deploy (push) Has been cancelled
2026-06-06 14:24:17 +03:30
09952319e8 feat(scripts): add upload to S3 scripts
Some checks failed
Deployment CI/CD / validate (push) Has been cancelled
Deployment CI/CD / deploy (push) Has been cancelled
2026-06-05 16:32:22 +03:30
52ef680771 fix(proxy): disable proxy service
Some checks failed
Deployment CI/CD / validate (push) Has been cancelled
Deployment CI/CD / deploy (push) Has been cancelled
2026-06-05 13:29:42 +03:30
9f7accba04 feat(scripts): add backup/restore scripts 2026-06-05 13:29:30 +03:30
863cbd9ec9 feat(proxy): add proxy service for google oauth connection
Some checks failed
Deployment CI/CD / validate (push) Has been cancelled
Deployment CI/CD / deploy (push) Has been cancelled
2026-05-22 00:30:11 +03:30
af0ffb2293 docs(deploy): correct google oauth callback settings
Some checks failed
Deployment CI/CD / validate (push) Has been cancelled
Deployment CI/CD / deploy (push) Has been cancelled
2026-05-21 19:15:47 +03:30
e015f01cd6 feat(deploy): add gitea actions deployment pipeline
Some checks failed
Deployment CI/CD / validate (push) Has been cancelled
Deployment CI/CD / deploy (push) Has been cancelled
2026-05-14 18:18:26 +03:30
e190825135 fix(nginx): remove api. subdomain and revert back to single domain for backend and fronted 2026-05-03 15:33:21 +03:30
9a764fafb4 fix(backend): add Django CSRF related variables 2026-05-03 15:32:32 +03:30
5ef7f18f77 chore(readme): add README.md 2026-05-01 10:49:38 +03:30
40f89fc9aa feat(deploy): add celery beat service 2026-04-30 20:23:22 +03:30
4d82094c0b fix(deploy): tune gunicorn for sse traffic 2026-04-30 10:04:41 +03:30
0a328156fa fix(deploy): add spa fallback in frontend image 2026-04-30 10:04:41 +03:30
a91606cc32 feat(deploy): serve backend from api subdomain 2026-04-29 20:16:18 +03:30
64be80da12 chore(deploy): update docs basic auth credentials 2026-04-29 18:09:58 +03:30
50eaf00cf2 fix(deploy): serve django media and static from shared volumes 2026-04-29 18:04:53 +03:30
34af725f41 feat(deploy): add qlockify.ir domain and ssl config 2026-04-29 17:27:49 +03:30
596e2716ab build(deploy): update backend image dependencies 2026-04-29 15:58:06 +03:30
8ab01bd5b7 chore(deploy): switch compose to nested app directories 2026-04-29 15:57:54 +03:30
8b6f25e068 build(deployment): use local backend and frontend build contexts 2026-04-25 17:27:10 +03:30
d3f14aeb78 feat(deployment): support sse notification streaming 2026-04-25 17:19:14 +03:30
19 changed files with 1594 additions and 203 deletions

View File

@@ -1,3 +1,12 @@
POSTGRES_DB=qlockify POSTGRES_DB=qlockify
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
S3_BACKUP_BUCKET=
S3_BACKUP_PREFIX=qlockify
S3_BACKUP_ENDPOINT_URL=
S3_BACKUP_ACCESS_KEY_ID=
S3_BACKUP_SECRET_ACCESS_KEY=
BACKUP_ENCRYPTION_PASSPHRASE=
BACKUP_LOCAL_KEEP_LATEST=3
BACKUP_REMOTE_KEEP_LATEST=7

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

View File

@@ -0,0 +1,85 @@
name: Deployment CI/CD
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
validate:
runs-on: qlockify-deploy
steps:
- name: Install dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bash ca-certificates git python3 python3-yaml
- name: Checkout repository
env:
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}.git
REPO_SHA: ${{ gitea.sha }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
WORKSPACE: ${{ gitea.workspace }}
run: |
mkdir -p "$WORKSPACE"
cd "$WORKSPACE"
git init
git remote add origin "$REPO_URL"
git -c http.extraHeader="Authorization: Bearer $GITEA_TOKEN" fetch --depth 1 origin "$REPO_SHA"
git checkout --detach FETCH_HEAD
- name: Validate deployment script
working-directory: ${{ gitea.workspace }}
run: bash -n scripts/deploy.sh
- name: Validate docker-compose.yml syntax
working-directory: ${{ gitea.workspace }}
run: |
python3 - <<'PY'
from pathlib import Path
import yaml
path = Path("docker-compose.yml")
data = yaml.safe_load(path.read_text(encoding="utf-8"))
assert isinstance(data, dict), "docker-compose.yml must contain a mapping at the top level"
assert "services" in data, "docker-compose.yml must define services"
PY
deploy:
if: github.event_name == 'push' && github.ref_name == 'main'
needs:
- validate
runs-on: qlockify-deploy
steps:
- name: Install SSH client
run: |
apt-get update
apt-get install -y --no-install-recommends bash openssh-client
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy updated infrastructure
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH }}
BACKEND_BRANCH: ${{ vars.BACKEND_BRANCH }}
FRONTEND_BRANCH: ${{ vars.FRONTEND_BRANCH }}
run: |
ssh -p "${DEPLOY_PORT:-22}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"DEPLOY_ROOT='${DEPLOY_PATH}' DEPLOY_BRANCH='${DEPLOY_BRANCH}' BACKEND_BRANCH='${BACKEND_BRANCH}' FRONTEND_BRANCH='${FRONTEND_BRANCH}' bash '${DEPLOY_PATH}/scripts/deploy.sh' deployment"

6
.gitignore vendored
View File

@@ -1,3 +1,9 @@
.env .env
backups/
backend/logs/ backend/logs/
backend/.pytest_cache/ backend/.pytest_cache/
backend/qlockify-backend-deployment
frontend/qlockify-frontend-deployment
nginx/certs/*
!nginx/certs/.gitkeep

496
README.md
View File

@@ -1,33 +1,493 @@
# Qlockify Deployment # Qlockify Deployment
This repository is the deployment layer only. Main deployment and operations repository for Qlockify.
It does not contain application source copies. Docker builds read directly from the sibling repositories: This repo is the entrypoint for running the full product stack in production.
- `../qlockify-backend` ## Related Repositories
- `../qlockify-frontend`
## Local structure - Deployment repository declared by `origin`: `https://git.amiirkhl.ir/Qlockify/qlockify-core-deployment.git`
- Backend repository declared by its `origin`: `https://git.amiirkhl.ir/Qlockify/qlockify-backend-deployment.git`
- Frontend repository declared by its `origin`: `https://git.amiirkhl.ir/Qlockify/qlockify-frontend-deployment.git`
The expected directory layout is: Use this repo for:
- Docker Compose orchestration
- Nginx
- SSL certificate mounting
- domain routing
- environment layout
- production service startup
Use the backend and frontend repos for application-level implementation details.
## What This Repo Contains
- `docker-compose.yml`
- Nginx config
- Postgres support files
- Dockerfiles for production images
- deployment environment samples
- container networking and volume wiring
## Architecture
Main deployed services:
- `nginx`
- `frontend`
- `backend`
- `celery`
- `celery-beat`
- `redis`
- `db`
Traffic pattern:
- `qlockify.ir` serves the frontend
- backend traffic is served from `qlockify.ir` under `/api` and `/admin`
- Nginx terminates TLS and proxies requests to the frontend and backend containers
## Expected Repository Layout
Docker builds read from nested application directories inside this repository:
- `./backend/qlockify-backend-deployment`
- `./frontend/qlockify-frontend-deployment`
Expected layout:
```text ```text
Qlockify/ qlockify-deployment/
qlockify-backend/ backend/
qlockify-frontend/ Dockerfile
qlockify-deployment/ .env.sample
qlockify-backend-deployment/
frontend/
Dockerfile
.env.sample
qlockify-frontend-deployment/
nginx/
postgres/
docker-compose.yml
``` ```
## Deployment flow ## Deployment Flow
1. Configure deployment env files: ### 1. Place application source
- `./.env`
- `./backend/.env` Put the app repos into:
- `./frontend/.env`
2. From `qlockify-deployment`, build and start the stack: - `./backend/qlockify-backend-deployment`
- `./frontend/qlockify-frontend-deployment`
### 2. Configure env files
Create and fill:
- `./.env`
- `./backend/qlockify-backend-deployment/.env`
- `./frontend/qlockify-frontend-deployment/.env`
### 3. Build and run
```powershell ```powershell
docker compose up --build docker compose up -d --build
``` ```
The backend container runs database migrations and `collectstatic` on startup, then serves Django with Gunicorn using `config.wsgi:application`. The backend container runs:
- database migrations
- `collectstatic`
- Gunicorn startup
## Domain and Routing
Configured domains:
- `qlockify.ir`
- `www.qlockify.ir`
Behavior:
- `www.qlockify.ir` redirects to `qlockify.ir`
- `http` redirects to `https`
- frontend is served from `qlockify.ir`
- backend traffic is proxied from `qlockify.ir/api` and `qlockify.ir/admin`
Before production startup:
1. Point DNS records for `qlockify.ir` and `www.qlockify.ir` to the server.
2. Make sure `80` and `443` are open on the server firewall.
3. Make sure the TLS certificate covers all required names.
## SSL Certificates
Place certificate files here:
```text
./nginx/certs/fullchain.pem
./nginx/certs/privkey.pem
```
The repository intentionally keeps only:
- `./nginx/certs/.gitkeep`
Real certificate files are ignored by git.
## Required Backend Environment
Set these in:
```text
./backend/qlockify-backend-deployment/.env
```
Core production values:
- `DJANGO_ALLOWED_HOSTS=qlockify.ir,www.qlockify.ir`
- `CORS_ALLOWED_ORIGINS=https://qlockify.ir,https://www.qlockify.ir`
- `CSRF_TRUSTED_ORIGINS=https://qlockify.ir,https://www.qlockify.ir`
- `BASE_URL=https://qlockify.ir`
- `POSTGRES_HOST=db`
- `REDIS_HOST=redis`
- `REDIS_URL=redis://redis:6379/0`
- `CELERY_BROKER_URL=redis://redis:6379/0`
- `CELERY_RESULT_BACKEND=redis://redis:6379/1`
Google OAuth values:
- `GOOGLE_OAUTH_CLIENT_ID=...`
- `GOOGLE_OAUTH_CLIENT_SECRET=...`
- `GOOGLE_OAUTH_REDIRECT_URI=https://qlockify.ir/api/users/oauth/google/callback/`
- `GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=https://qlockify.ir/auth/google/callback`
## Required Frontend Environment
Set this in:
```text
./frontend/qlockify-frontend-deployment/.env
```
```text
VITE_API_BASE_URL=/api
```
## Background Workers
This stack includes:
- `celery` for async jobs
- `celery-beat` for scheduled jobs
If background scheduling stops working, inspect:
```powershell
docker compose logs -f celery
docker compose logs -f celery-beat
```
## Notifications and SSE
Notifications use Server-Sent Events at `/api/notifications/stream/`.
Current behavior:
- Nginx disables buffering for the SSE endpoint
- Gunicorn is tuned to tolerate connected streams for current traffic
- if concurrency grows materially, move SSE to async workers or a dedicated ASGI service
## Useful Operations
Build/rebuild:
```powershell
docker compose up -d --build
```
Restart a subset:
```powershell
docker compose up -d --build nginx backend frontend
```
Inspect running services:
```powershell
docker compose ps
```
Follow logs:
```powershell
docker compose logs -f nginx
docker compose logs -f backend
docker compose logs -f celery
docker compose logs -f celery-beat
```
Stop everything:
```powershell
docker compose down
```
Backup the deployed data:
```bash
chmod +x ./scripts/backup.sh
./scripts/backup.sh
```
By default, backup archives are written to `./backups/`. Each archive contains:
- PostgreSQL data from the `db` service
- media files from the Docker media volume
- `.env` files from the deployment, backend, and frontend projects
Restore a backup archive on another machine:
```bash
chmod +x ./scripts/restore.sh
./scripts/restore.sh ./backups/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz
docker compose up -d --build
```
Restore is destructive for the database by default. To restore only part of an archive, use:
```bash
RESTORE_SKIP_DB=1 ./scripts/restore.sh ./backups/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz
RESTORE_SKIP_MEDIA=1 ./scripts/restore.sh ./backups/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz
RESTORE_SKIP_ENV=1 ./scripts/restore.sh ./backups/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz
```
Install backup upload prerequisites:
```bash
sudo apt update
sudo apt install -y rclone openssl
```
Configure encrypted S3-compatible backup uploads in `./.env`:
```bash
S3_BACKUP_BUCKET=qlockify-backups
S3_BACKUP_PREFIX=qlockify
S3_BACKUP_ENDPOINT_URL=https://c284984.parspack.net
S3_BACKUP_ACCESS_KEY_ID=
S3_BACKUP_SECRET_ACCESS_KEY=
BACKUP_ENCRYPTION_PASSPHRASE=
BACKUP_LOCAL_KEEP_LATEST=3
BACKUP_REMOTE_KEEP_LATEST=7
```
Upload an encrypted backup to S3-compatible object storage with rclone:
```bash
chmod +x ./scripts/backup-upload-s3.sh
./scripts/backup-upload-s3.sh
```
After a successful upload, the remote storage keeps only the latest `BACKUP_REMOTE_KEEP_LATEST` encrypted `.tar.gz.enc` files. The server keeps only the latest `BACKUP_LOCAL_KEEP_LATEST` plaintext `.tar.gz` files under `./backups/latest/`; encrypted backup files are not kept locally.
Restore the latest encrypted backup from S3:
```bash
chmod +x ./scripts/restore-from-s3.sh
./scripts/restore-from-s3.sh latest
docker compose up -d --build
```
Restore a specific encrypted backup from S3:
```bash
./scripts/restore-from-s3.sh qlockify/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz.enc
docker compose up -d --build
```
Schedule daily encrypted uploads with cron:
```cron
0 2 * * * cd /home/ubuntu/qlockify-deployment && ./scripts/backup-upload-s3.sh >> ./backups/backup.log 2>&1
```
The S3 restore script supports the same partial restore flags:
```bash
RESTORE_SKIP_DB=1 ./scripts/restore-from-s3.sh latest
RESTORE_SKIP_MEDIA=1 ./scripts/restore-from-s3.sh latest
RESTORE_SKIP_ENV=1 ./scripts/restore-from-s3.sh latest
```
Keep `BACKUP_ENCRYPTION_PASSPHRASE` in a safe place outside the server. Encrypted backups cannot be restored without it.
## CI/CD with Gitea Actions
This repository now ships with a Gitea Actions deployment workflow in:
- `.gitea/workflows/deploy.yml`
The backend and frontend repositories each ship with their own workflow files:
- backend: `.gitea/workflows/backend.yml`
- frontend: `.gitea/workflows/frontend.yml`
Deployment behavior:
- backend repo push to `main`: runs backend CI, then updates the backend checkout on the server and rebuilds `backend`, `celery`, and `celery-beat`
- frontend repo push to `main`: runs frontend lint/build, then updates the frontend checkout on the server and rebuilds `frontend`
- deployment repo push to `main`: validates deployment files, then updates the deployment checkout on the server and rebuilds `nginx` plus the app services
The remote deploy entrypoint is:
- `./scripts/deploy.sh`
### One-Time Server Bootstrap
Before Actions can deploy automatically, make sure the server is prepared once.
1. Clone all three repositories on the server into the expected layout:
```text
~/qlockify-deployment
~/qlockify-deployment/backend/qlockify-backend-deployment
~/qlockify-deployment/frontend/qlockify-frontend-deployment
```
2. Make sure the server can `git fetch` all three repositories non-interactively.
Recommended approach:
- add a deploy SSH key on the server
- add the public key to Gitea as a deploy key or a machine-user SSH key
- switch the server-side git remotes to SSH URLs
3. Pull the latest deployment repo once so the server has `scripts/deploy.sh`.
4. Make the deploy script executable:
```bash
chmod +x ~/qlockify-deployment/scripts/deploy.sh
```
5. Make sure the deploy user can run Docker Compose on the server.
### Gitea Runner Setup
Gitea Actions requires a trusted runner. Gitea's official docs describe the runner and label model here:
- Actions overview: `https://docs.gitea.com/usage/actions/overview`
- Act Runner: `https://docs.gitea.com/usage/actions/act-runner`
Recommended label setup for this project:
```text
qlockify-python:docker://python:3.14-bookworm
qlockify-node:docker://node:22-bookworm
qlockify-deploy:docker://ubuntu:24.04
```
Example non-interactive runner registration:
```bash
./act_runner register \
--no-interactive \
--instance https://git.amiirkhl.ir \
--token <runner_registration_token> \
--name qlockify-runner \
--labels "qlockify-python:docker://python:3.14-bookworm,qlockify-node:docker://node:22-bookworm,qlockify-deploy:docker://ubuntu:24.04"
```
Then start the runner daemon:
```bash
./act_runner daemon
```
### Gitea Secrets
Create these Actions secrets either at the `Qlockify` organization level or per repository.
- `SSH_PRIVATE_KEY`
- private key used by the workflow to SSH into the deployment server
- `SSH_KNOWN_HOSTS`
- output of:
```bash
ssh-keyscan -H <your-server-hostname-or-ip>
```
Do not create `GITEA_TOKEN` manually. Gitea provides a built-in job token and exposes it as `${{ secrets.GITEA_TOKEN }}`. See:
- `https://docs.gitea.com/usage/actions/token-permissions`
### Gitea Variables
Create these Actions variables in the Gitea UI:
- `DEPLOY_HOST`
- `DEPLOY_PORT`
- `DEPLOY_USER`
- `DEPLOY_PATH`
- `DEPLOY_BRANCH`
- `BACKEND_BRANCH`
- `FRONTEND_BRANCH`
Suggested values for your current server layout:
```text
DEPLOY_HOST=h9arjloaye
DEPLOY_PORT=22
DEPLOY_USER=ubuntu
DEPLOY_PATH=/home/ubuntu/qlockify-deployment
DEPLOY_BRANCH=main
BACKEND_BRANCH=main
FRONTEND_BRANCH=main
```
Gitea variables are available in workflows through `${{ vars.NAME }}`. Gitea documents that here:
- `https://docs.gitea.com/usage/actions/actions-variables`
### Recommended UI Setup
If all three repositories live under the same `Qlockify` organization, the cleanest setup is:
1. Add one organization-level runner to `Qlockify`
2. Add organization-level variables for the shared deploy target
3. Add organization-level secrets for the SSH key and known hosts
This keeps the three repositories consistent and avoids copying the same values three times.
### First Deploy Check
After creating the runner, variables, and secrets:
1. push the deployment repository first
2. confirm the deployment workflow succeeds
3. then push backend or frontend changes and confirm their repo-specific workflows deploy only the affected services
If a workflow fails on the server step, first check:
- runner logs
- repository Actions logs
- `docker compose logs -f backend`
- `docker compose logs -f frontend`
- `docker compose logs -f celery`
- `docker compose logs -f celery-beat`
## Scope Boundary
This repo should document:
- infrastructure
- runtime topology
- domains
- Nginx
- Docker Compose
- SSL
- operational startup and troubleshooting
It should not duplicate the application-specific implementation details already documented in the backend and frontend repositories.

View File

@@ -5,7 +5,7 @@ DEBUG=True
# Django Core # Django Core
DJANGO_SETTINGS_MODULE=config.settings DJANGO_SETTINGS_MODULE=config.settings
DJANGO_SECRET_KEY= DJANGO_SECRET_KEY=
DJANGO_ALLOWED_HOSTS= DJANGO_ALLOWED_HOSTS=api.qlockify.ir,qlockify.ir,www.qlockify.ir
# Database # Database
POSTGRES_DB=app_db POSTGRES_DB=app_db
@@ -15,8 +15,11 @@ POSTGRES_HOST=db
POSTGRES_PORT=5432 POSTGRES_PORT=5432
# CORS / CSRF # CORS / CSRF
CORS_ALLOWED_ORIGINS=https://app.example.com CORS_ALLOWED_ORIGINS=https://qlockify.ir,https://www.qlockify.ir
CSRF_TRUSTED_ORIGINS=https://app.example.com CSRF_TRUSTED_ORIGINS=https://api.qlockify.ir,https://qlockify.ir,https://www.qlockify.ir
DJANGO_CORS_ALLOWED_ORIGINS=https://qlockify.ir,https://www.qlockify.ir
DJANGO_CSRF_TRUSTED_ORIGINS=https://api.qlockify.ir,https://qlockify.ir,https://www.qlockify.ir
# JWT # JWT
ACCESS_TOKEN_LIFETIME=5 ACCESS_TOKEN_LIFETIME=5
@@ -41,4 +44,8 @@ LANGUAGE_CODE=en-us
TIME_ZONE=Asia/Tehran TIME_ZONE=Asia/Tehran
SMS_APIKEY= SMS_APIKEY=
BASE_URL= BASE_URL=https://qlockify.ir
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URI=https://qlockify.ir/api/users/oauth/google/callback/
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=https://qlockify.ir/auth/google/callback

View File

@@ -1,25 +1,34 @@
FROM python:3.14-slim FROM python:3.14
WORKDIR /app WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV PIP_INDEX_URL=https://package-mirror.liara.ir/repository/pypi/simple
# Adapted Runflare mirror for Debian-based official Python image # ---- APT Iran-safe configuration ----
RUN . /etc/os-release && \ RUN rm -f /etc/apt/sources.list.d/debian.sources && \
echo "deb http://mirror-linux.runflare.com/debian $VERSION_CODENAME main" > /etc/apt/sources.list && \ printf '%s\n' \
echo "deb http://mirror-linux.runflare.com/debian $VERSION_CODENAME-updates main" >> /etc/apt/sources.list && \ 'deb http://mirror.arvancloud.ir/debian trixie main contrib non-free non-free-firmware' \
echo "deb http://mirror-linux.runflare.com/debian-security $VERSION_CODENAME-security main" >> /etc/apt/sources.list 'deb http://mirror.arvancloud.ir/debian trixie-updates main contrib non-free non-free-firmware' \
'deb http://mirror.arvancloud.ir/debian-security trixie-security main contrib non-free non-free-firmware' \
> /etc/apt/sources.list && \
echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99no-check-valid
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y gcc libpq-dev \ && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
rustc \
cargo \
pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY qlockify-backend/requirements/ /app/requirements/ COPY requirements/ /app/requirements/
RUN pip install --no-cache-dir -r requirements/base.txt \
&& pip install --no-cache-dir -r requirements/prod.txt
COPY qlockify-backend/ . RUN pip install --no-cache-dir --upgrade pip setuptools wheel -i https://package-mirror.liara.ir/repository/pypi/simple \
&& pip install --no-cache-dir -r requirements/base.txt -i https://package-mirror.liara.ir/repository/pypi/simple \
&& pip install --no-cache-dir -r requirements/prod.txt -i https://package-mirror.liara.ir/repository/pypi/simple
COPY . .
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]

View File

@@ -1,15 +1,28 @@
services: services:
# proxy:
# build:
# context: ../proxy
# dockerfile: Dockerfile
# restart: unless-stopped
# expose:
# - "8085"
# volumes:
# - ../proxy/config.json:/app/config.json:ro
# - ../proxy/ca:/app/ca
# environment:
# PYTHONUNBUFFERED: "1"
# PROXY_HOST: "0.0.0.0"
# PROXY_PORT: "8085"
db: db:
image: postgres:18-alpine image: postgres:18-alpine
restart: always restart: always
env_file: env_file:
- .env - ./backend/qlockify-backend-deployment/.env
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql
- ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
- ./postgres/custom-postgresql.conf:/etc/postgresql/postgresql.conf:ro command: postgres
- ./postgres/pg_hba.conf:/var/lib/postgresql/data/pg_hba.conf:ro
command: postgres -c config_file=/etc/postgresql/postgresql.conf
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s interval: 10s
@@ -26,18 +39,24 @@ services:
backend: backend:
build: build:
context: .. context: ./backend/qlockify-backend-deployment
dockerfile: qlockify-deployment/backend/Dockerfile dockerfile: ../Dockerfile
restart: always restart: always
env_file: env_file:
- ./backend/.env - ./backend/qlockify-backend-deployment/.env
volumes: volumes:
- static_data:/app/staticfiles - static_data:/app/static
- media_data:/app/mediafiles - media_data:/app/media
command: > command: >
sh -c "python manage.py migrate && sh -c "python manage.py migrate &&
python manage.py collectstatic --noinput && python manage.py collectstatic --noinput &&
gunicorn config.wsgi:application --bind 0.0.0.0:8000" gunicorn config.wsgi:application
--bind 0.0.0.0:8000
--worker-class gthread
--workers 2
--threads 8
--timeout 120
--keep-alive 75"
expose: expose:
- "8000" - "8000"
depends_on: depends_on:
@@ -45,16 +64,25 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_started condition: service_started
# proxy:
# condition: service_started
# environment:
# HTTP_PROXY: "http://proxy:8085"
# HTTPS_PROXY: "http://proxy:8085"
# http_proxy: "http://proxy:8085"
# https_proxy: "http://proxy:8085"
# NO_PROXY: "localhost,127.0.0.1,db,redis,proxy"
# no_proxy: "localhost,127.0.0.1,db,redis,proxy"
celery: celery:
build: build:
context: .. context: ./backend/qlockify-backend-deployment
dockerfile: qlockify-deployment/backend/Dockerfile dockerfile: ../Dockerfile
restart: always restart: always
env_file: env_file:
- ./backend/.env - ./backend/qlockify-backend-deployment/.env
volumes: volumes:
- media_data:/app/mediafiles - media_data:/app/media
command: celery -A config worker -l INFO command: celery -A config worker -l INFO
depends_on: depends_on:
db: db:
@@ -64,13 +92,31 @@ services:
backend: backend:
condition: service_started condition: service_started
celery-beat:
build:
context: ./backend/qlockify-backend-deployment
dockerfile: ../Dockerfile
restart: always
env_file:
- ./backend/qlockify-backend-deployment/.env
volumes:
- celery_beat_data:/app/run
command: celery -A config beat -l INFO --schedule /app/run/celerybeat-schedule
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
backend:
condition: service_started
frontend: frontend:
build: build:
context: .. context: ./frontend/qlockify-frontend-deployment
dockerfile: qlockify-deployment/frontend/Dockerfile dockerfile: ../Dockerfile
restart: always restart: always
env_file: env_file:
- ./frontend/.env - ./frontend/qlockify-frontend-deployment/.env
expose: expose:
- "80" - "80"
@@ -79,12 +125,13 @@ services:
restart: always restart: always
ports: ports:
- "80:80" - "80:80"
# - "443:443" # Uncomment when adding SSL - "443:443"
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
- ./nginx/.htpasswd:/etc/nginx/.htpasswd:ro - ./nginx/.htpasswd:/etc/nginx/.htpasswd:ro
- static_data:/usr/share/nginx/html/staticfiles:ro - static_data:/usr/share/nginx/html/static:ro
- media_data:/usr/share/nginx/html/mediafiles:ro - media_data:/usr/share/nginx/html/media:ro
depends_on: depends_on:
- backend - backend
- frontend - frontend
@@ -93,3 +140,4 @@ volumes:
postgres_data: postgres_data:
static_data: static_data:
media_data: media_data:
celery_beat_data:

View File

@@ -1 +1 @@
VITE_API_BASE_URL=http://localhost/api VITE_API_BASE_URL=https://qlockify.ir

View File

@@ -4,15 +4,32 @@ WORKDIR /app
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ --global RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ --global
COPY qlockify-frontend/package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY qlockify-frontend/ . COPY . .
RUN npm run build RUN npm run build
FROM nginx:alpine FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# Internal Nginx configuration (Root Nginx acts as reverse proxy to this)
RUN cat <<'EOF' > /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /assets/ {
try_files $uri =404;
access_log off;
expires 30d;
}
location / {
try_files $uri $uri/ /index.html;
}
}
EOF
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1 +1 @@
admin:$2y$05$gSZ4s3BN8TsEc.pS/vaZi.v/AMrIozncWtFDGkNOglJlv59f7jc7i Admin:$2y$10$YQpMsUAwPos59XKbWQRiSOxBJI4WmMbmTJBVy2ZRz/42FG.dj4W8K

1
nginx/certs/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -1,24 +1,54 @@
server { server {
listen 80; listen 80;
server_name localhost; server_name qlockify.ir www.qlockify.ir;
return 301 https://qlockify.ir$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name www.qlockify.ir;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
return 301 https://qlockify.ir$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name qlockify.ir;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
client_max_body_size 100M; client_max_body_size 100M;
sendfile on; sendfile on;
# Static and Media files # Static and Media files
location /static/ { location /static/ {
alias /usr/share/nginx/html/staticfiles/; alias /usr/share/nginx/html/static/;
expires 30d; expires 30d;
access_log off; access_log off;
} }
location /media/ { location /media/ {
alias /usr/share/nginx/html/mediafiles/; alias /usr/share/nginx/html/media/;
expires 30d; expires 30d;
access_log off; access_log off;
} }
# Protect API Documentation with Basic Auth (from your old project) # Protect API Documentation with Basic Auth
location ~ ^/(docs|redoc|openapi.json|api/docs|api/redoc|api/openapi.json|api/v1/docs) { location ~ ^/(docs|redoc|openapi.json|api/docs|api/redoc|api/openapi.json|api/v1/docs) {
auth_basic "Restricted API Documentation"; auth_basic "Restricted API Documentation";
auth_basic_user_file /etc/nginx/.htpasswd; auth_basic_user_file /etc/nginx/.htpasswd;
@@ -30,26 +60,42 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Standard API Proxy location /api/notifications/stream/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header X-Accel-Buffering no;
}
location /api/ { location /api/ {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
# Admin Panel Proxy
location /admin/ { location /admin/ {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
# Frontend Proxy
location / { location / {
proxy_pass http://frontend:80; proxy_pass http://frontend:80;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
} }

View File

@@ -1,11 +0,0 @@
# TYPE DATABASE USER ADDRESS METHOD
local all all scram-sha-256
host all all 127.0.0.1/32 scram-sha-256
# Allow Docker containers to connect (Standard Docker bridge subnets)
host all all 172.16.0.0/12 scram-sha-256
host all all 192.168.0.0/16 scram-sha-256
# Reject everything else
host all all 0.0.0.0/0 reject
host all all ::/0 reject

213
scripts/backup-upload-s3.sh Executable file
View File

@@ -0,0 +1,213 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
cat <<'EOF'
Usage:
backup-upload-s3.sh
Environment:
DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment.
S3_BACKUP_ENDPOINT_URL S3 endpoint URL.
S3_BACKUP_ACCESS_KEY_ID S3 access key.
S3_BACKUP_SECRET_ACCESS_KEY S3 secret key.
S3_BACKUP_BUCKET Bucket name.
S3_BACKUP_PREFIX Object prefix. Defaults to qlockify.
BACKUP_ENCRYPTION_PASSPHRASE Encryption passphrase.
BACKUP_LOCAL_KEEP_LATEST Number of latest local plaintext backups to keep. Defaults to 3.
BACKUP_REMOTE_KEEP_LATEST Number of latest remote encrypted backups to keep. Defaults to 7.
EOF
}
log() {
printf '[backup-rclone] %s\n' "$*"
}
fail() {
printf '[backup-rclone] %s\n' "$*" >&2
exit 1
}
require_var() {
local name="$1"
[[ -n "${!name:-}" ]] || fail "$name is required"
}
load_env() {
local env_path="$1"
[[ -f "$env_path" ]] || fail "Deployment env file not found: $env_path"
set -a
# shellcheck disable=SC1090
. "$env_path"
set +a
}
normalize_prefix() {
local value="${1:-qlockify}"
value="${value#/}"
value="${value%/}"
printf '%s' "$value"
}
positive_integer_or_default() {
local value="${1:-}"
local fallback="$2"
if [[ "$value" =~ ^[0-9]+$ ]]; then
printf '%s' "$value"
else
printf '%s' "$fallback"
fi
}
rclone_remote_path() {
printf 'parspack:%s/%s' "$S3_BACKUP_BUCKET" "$S3_BACKUP_PREFIX"
}
cleanup_local_backups() {
local keep_count="$1"
local latest_dir="$2"
[[ "$keep_count" -gt 0 ]] || {
rm -rf "$latest_dir"
return
}
mkdir -p "$latest_dir"
find "$latest_dir" -maxdepth 1 -type f -name '*.tar.gz.enc' -delete
cp "$ARCHIVE_PATH" "$latest_dir/$ARCHIVE_NAME"
find "$latest_dir" -maxdepth 1 -type f -name '*.tar.gz' -printf '%T@ %p\n' \
| sort -nr \
| awk -v keep="$keep_count" 'NR > keep {print $2}' \
| xargs -r rm -f
log "Kept latest $keep_count plaintext backup(s) locally in: $latest_dir"
}
cleanup_remote_backups() {
local keep_count="$1"
local remote_path="$2"
local stale_files
[[ "$keep_count" -gt 0 ]] || {
log "Skipping remote cleanup because BACKUP_REMOTE_KEEP_LATEST is $keep_count"
return
}
stale_files="$(
rclone lsf "$remote_path" \
--config "$RCLONE_CONFIG" \
--s3-no-check-bucket \
--files-only \
| grep '\.tar\.gz\.enc$' \
| sort -r \
| awk -v keep="$keep_count" 'NR > keep' \
|| true
)"
if [[ -z "$stale_files" ]]; then
log "No remote backups need cleanup"
return
fi
while IFS= read -r stale_file; do
[[ -n "$stale_file" ]] || continue
log "Deleting old remote backup: $remote_path/$stale_file"
rclone deletefile "$remote_path/$stale_file" \
--config "$RCLONE_CONFIG" \
--s3-no-check-bucket
done <<< "$stale_files"
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
command -v rclone >/dev/null 2>&1 || fail "rclone is required"
command -v openssl >/dev/null 2>&1 || fail "openssl is required"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_ROOT="${DEPLOY_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
DEPLOY_ENV="$DEPLOY_ROOT/.env"
BACKUP_SCRIPT="$SCRIPT_DIR/backup.sh"
[[ -x "$BACKUP_SCRIPT" ]] || fail "Backup script is not executable: $BACKUP_SCRIPT"
load_env "$DEPLOY_ENV"
require_var S3_BACKUP_ENDPOINT_URL
require_var S3_BACKUP_ACCESS_KEY_ID
require_var S3_BACKUP_SECRET_ACCESS_KEY
require_var S3_BACKUP_BUCKET
require_var BACKUP_ENCRYPTION_PASSPHRASE
S3_BACKUP_PREFIX="$(normalize_prefix "${S3_BACKUP_PREFIX:-qlockify}")"
BACKUP_LOCAL_KEEP_LATEST="$(positive_integer_or_default "${BACKUP_LOCAL_KEEP_LATEST:-3}" 3)"
BACKUP_REMOTE_KEEP_LATEST="$(positive_integer_or_default "${BACKUP_REMOTE_KEEP_LATEST:-7}" 7)"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
PLAIN_DIR="$WORK_DIR/plain"
mkdir -p "$PLAIN_DIR"
log "Creating local backup archive"
DEPLOY_ROOT="$DEPLOY_ROOT" "$BACKUP_SCRIPT" "$PLAIN_DIR"
shopt -s nullglob
archives=("$PLAIN_DIR"/*.tar.gz)
shopt -u nullglob
[[ "${#archives[@]}" -eq 1 ]] || fail "Expected exactly one backup archive, found ${#archives[@]}"
ARCHIVE_PATH="${archives[0]}"
ARCHIVE_NAME="$(basename "$ARCHIVE_PATH")"
ENCRYPTED_NAME="$ARCHIVE_NAME.enc"
ENCRYPTED_PATH="$WORK_DIR/$ENCRYPTED_NAME"
log "Encrypting backup archive"
openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \
-in "$ARCHIVE_PATH" \
-out "$ENCRYPTED_PATH" \
-pass env:BACKUP_ENCRYPTION_PASSPHRASE
RCLONE_CONFIG="$WORK_DIR/rclone.conf"
cat > "$RCLONE_CONFIG" <<EOF
[parspack]
type = s3
provider = Other
access_key_id = $S3_BACKUP_ACCESS_KEY_ID
secret_access_key = $S3_BACKUP_SECRET_ACCESS_KEY
endpoint = $S3_BACKUP_ENDPOINT_URL
acl = private
force_path_style = true
EOF
REMOTE_PATH="$(rclone_remote_path)"
log "Uploading encrypted backup to $REMOTE_PATH/$ENCRYPTED_NAME"
rclone copy \
"$ENCRYPTED_PATH" \
"$REMOTE_PATH" \
--config "$RCLONE_CONFIG" \
--s3-no-check-bucket \
--progress
cleanup_local_backups "$BACKUP_LOCAL_KEEP_LATEST" "$DEPLOY_ROOT/backups/latest"
cleanup_remote_backups "$BACKUP_REMOTE_KEEP_LATEST" "$REMOTE_PATH"
log "Backup upload completed successfully"

126
scripts/backup.sh Executable file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
cat <<'EOF'
Usage:
backup.sh [output-directory]
Environment:
DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment.
Creates a timestamped .tar.gz archive containing:
- PostgreSQL dump from the db service
- media files from the backend media volume
- deployment, backend, and frontend .env files
EOF
}
log() {
printf '[backup] %s\n' "$*"
}
fail() {
printf '[backup] %s\n' "$*" >&2
exit 1
}
require_file() {
local path="$1"
[[ -f "$path" ]] || fail "Required file not found: $path"
}
compose() {
docker compose -f "$DEPLOY_ROOT/docker-compose.yml" "$@"
}
wait_for_db() {
compose exec -T db sh -c '
set -eu
: "${POSTGRES_USER:?POSTGRES_USER is missing}"
: "${POSTGRES_DB:?POSTGRES_DB is missing}"
until pg_isready --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"; do
sleep 1
done
' >/dev/null
}
copy_env_if_exists() {
local source_path="$1"
local target_path="$2"
if [[ -f "$source_path" ]]; then
cp "$source_path" "$target_path"
fi
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/qlockify-deployment}"
OUTPUT_DIR="${1:-$DEPLOY_ROOT/backups}"
BACKEND_ENV="$DEPLOY_ROOT/backend/qlockify-backend-deployment/.env"
FRONTEND_ENV="$DEPLOY_ROOT/frontend/qlockify-frontend-deployment/.env"
DEPLOY_ENV="$DEPLOY_ROOT/.env"
require_file "$DEPLOY_ROOT/docker-compose.yml"
require_file "$BACKEND_ENV"
mkdir -p "$OUTPUT_DIR"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
TIMESTAMP="$(date -u +'%Y%m%d-%H%M%S')"
ARCHIVE_NAME="qlockify-backup-$TIMESTAMP.tar.gz"
ARCHIVE_PATH="$OUTPUT_DIR/$ARCHIVE_NAME"
mkdir -p "$WORK_DIR/env"
log "Checking Docker Compose configuration"
compose config -q
log "Starting database service if needed"
compose up -d db
wait_for_db
log "Dumping database from db service"
compose exec -T db sh -c '
set -eu
: "${POSTGRES_USER:?POSTGRES_USER is missing}"
: "${POSTGRES_DB:?POSTGRES_DB is missing}"
pg_dump \
--username="$POSTGRES_USER" \
--dbname="$POSTGRES_DB" \
--format=plain \
--clean \
--if-exists \
--no-owner \
--no-privileges
' > "$WORK_DIR/database.sql"
log "Archiving media files from backend media volume"
if compose ps --status running --services | grep -qx 'backend'; then
compose exec -T backend sh -c 'mkdir -p /app/media && tar -C /app/media -czf - .' > "$WORK_DIR/media.tar.gz"
else
log "Backend service is not running; using a temporary container for media volume"
compose run --rm --no-deps --entrypoint sh backend -c 'mkdir -p /app/media && tar -C /app/media -czf - .' > "$WORK_DIR/media.tar.gz"
fi
log "Copying environment files"
copy_env_if_exists "$DEPLOY_ENV" "$WORK_DIR/env/deployment.env"
copy_env_if_exists "$BACKEND_ENV" "$WORK_DIR/env/backend.env"
copy_env_if_exists "$FRONTEND_ENV" "$WORK_DIR/env/frontend.env"
cat > "$WORK_DIR/manifest.txt" <<EOF
name=$ARCHIVE_NAME
created_at_utc=$TIMESTAMP
deploy_root=$DEPLOY_ROOT
includes=database.sql,media.tar.gz,env/deployment.env,env/backend.env,env/frontend.env
EOF
log "Creating archive $ARCHIVE_PATH"
tar -C "$WORK_DIR" -czf "$ARCHIVE_PATH" .
log "Backup completed: $ARCHIVE_PATH"

92
scripts/deploy.sh Normal file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
cat <<'EOF'
Usage:
deploy.sh <component>
Components:
deployment Update the deployment repo and rebuild nginx + app services
backend Update the backend repo and rebuild backend + celery services
frontend Update the frontend repo and rebuild frontend
full Update all three repos and rebuild all app services
EOF
}
log() {
printf '[deploy] %s\n' "$*"
}
require_git_repo() {
local path="$1"
if [[ ! -d "$path/.git" ]]; then
printf 'Expected git repository at %s\n' "$path" >&2
exit 1
fi
}
sync_repo() {
local path="$1"
local branch="$2"
require_git_repo "$path"
log "Syncing $path -> origin/$branch"
git -C "$path" fetch --prune origin
git -C "$path" checkout "$branch"
git -C "$path" reset --hard "origin/$branch"
}
compose() {
docker compose -f "$DEPLOY_ROOT/docker-compose.yml" "$@"
}
COMPONENT="${1:-}"
if [[ -z "$COMPONENT" ]]; then
usage
exit 1
fi
DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/qlockify-deployment}"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
BACKEND_BRANCH="${BACKEND_BRANCH:-main}"
FRONTEND_BRANCH="${FRONTEND_BRANCH:-main}"
DEPLOY_REPO_PATH="$DEPLOY_ROOT"
BACKEND_REPO_PATH="$DEPLOY_ROOT/backend/qlockify-backend-deployment"
FRONTEND_REPO_PATH="$DEPLOY_ROOT/frontend/qlockify-frontend-deployment"
cd "$DEPLOY_ROOT"
case "$COMPONENT" in
deployment)
sync_repo "$DEPLOY_REPO_PATH" "$DEPLOY_BRANCH"
compose config -q
compose up -d --build nginx backend frontend celery celery-beat
;;
backend)
sync_repo "$DEPLOY_REPO_PATH" "$DEPLOY_BRANCH"
sync_repo "$BACKEND_REPO_PATH" "$BACKEND_BRANCH"
compose config -q
compose up -d --build backend celery celery-beat
;;
frontend)
sync_repo "$DEPLOY_REPO_PATH" "$DEPLOY_BRANCH"
sync_repo "$FRONTEND_REPO_PATH" "$FRONTEND_BRANCH"
compose config -q
compose up -d --build frontend
;;
full)
sync_repo "$DEPLOY_REPO_PATH" "$DEPLOY_BRANCH"
sync_repo "$BACKEND_REPO_PATH" "$BACKEND_BRANCH"
sync_repo "$FRONTEND_REPO_PATH" "$FRONTEND_BRANCH"
compose config -q
compose up -d --build nginx backend frontend celery celery-beat
;;
*)
usage
exit 1
;;
esac
compose ps

148
scripts/restore-from-s3.sh Executable file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
cat <<'EOF'
Usage:
restore-from-s3.sh latest
restore-from-s3.sh <s3-object-key>
Examples:
restore-from-s3.sh latest
restore-from-s3.sh qlockify/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz.enc
Environment:
DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment.
S3_BACKUP_BUCKET S3 bucket name.
S3_BACKUP_PREFIX S3 object prefix. Defaults to qlockify.
S3_BACKUP_ENDPOINT_URL S3-compatible endpoint URL.
S3_BACKUP_ACCESS_KEY_ID S3 access key.
S3_BACKUP_SECRET_ACCESS_KEY S3 secret key.
BACKUP_ENCRYPTION_PASSPHRASE Passphrase used to decrypt backup archives.
Existing RESTORE_SKIP_DB, RESTORE_SKIP_MEDIA, and RESTORE_SKIP_ENV flags are
passed through to restore.sh.
EOF
}
log() {
printf '[restore-rclone] %s\n' "$*"
}
fail() {
printf '[restore-rclone] %s\n' "$*" >&2
exit 1
}
require_var() {
local name="$1"
[[ -n "${!name:-}" ]] || fail "$name is required"
}
load_env() {
local env_path="$1"
[[ -f "$env_path" ]] || fail "Deployment env file not found: $env_path"
set -a
# shellcheck disable=SC1090
. "$env_path"
set +a
}
normalize_prefix() {
local value="${1:-qlockify}"
value="${value#/}"
value="${value%/}"
printf '%s' "$value"
}
rclone_remote_path() {
printf 'parspack:%s/%s' "$S3_BACKUP_BUCKET" "$S3_BACKUP_PREFIX"
}
latest_object_name() {
rclone lsf "$REMOTE_PATH" \
--config "$RCLONE_CONFIG" \
--s3-no-check-bucket \
--files-only \
| grep '\.tar\.gz\.enc$' \
| sort \
| tail -n 1 \
|| true
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
REQUESTED_KEY="${1:-}"
[[ -n "$REQUESTED_KEY" ]] || {
usage
exit 1
}
command -v rclone >/dev/null 2>&1 || fail "rclone is required"
command -v openssl >/dev/null 2>&1 || fail "openssl is required"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_ROOT="${DEPLOY_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
DEPLOY_ENV="$DEPLOY_ROOT/.env"
RESTORE_SCRIPT="$SCRIPT_DIR/restore.sh"
[[ -x "$RESTORE_SCRIPT" ]] || fail "Restore script is not executable: $RESTORE_SCRIPT"
load_env "$DEPLOY_ENV"
S3_BACKUP_PREFIX="$(normalize_prefix "${S3_BACKUP_PREFIX:-qlockify}")"
require_var S3_BACKUP_BUCKET
require_var S3_BACKUP_ENDPOINT_URL
require_var S3_BACKUP_ACCESS_KEY_ID
require_var S3_BACKUP_SECRET_ACCESS_KEY
require_var BACKUP_ENCRYPTION_PASSPHRASE
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
RCLONE_CONFIG="$WORK_DIR/rclone.conf"
cat > "$RCLONE_CONFIG" <<EOF
[parspack]
type = s3
provider = Other
access_key_id = $S3_BACKUP_ACCESS_KEY_ID
secret_access_key = $S3_BACKUP_SECRET_ACCESS_KEY
endpoint = $S3_BACKUP_ENDPOINT_URL
acl = private
force_path_style = true
EOF
REMOTE_PATH="$(rclone_remote_path)"
if [[ "$REQUESTED_KEY" == "latest" ]]; then
log "Resolving latest encrypted backup object"
OBJECT_NAME="$(latest_object_name)"
[[ -n "$OBJECT_NAME" ]] || fail "No encrypted backups found in $REMOTE_PATH"
else
OBJECT_NAME="${REQUESTED_KEY##*/}"
fi
ENCRYPTED_PATH="$WORK_DIR/$OBJECT_NAME"
DECRYPTED_PATH="$WORK_DIR/${ENCRYPTED_PATH##*/}"
DECRYPTED_PATH="${DECRYPTED_PATH%.enc}"
log "Downloading encrypted backup from $REMOTE_PATH/$OBJECT_NAME"
rclone copyto "$REMOTE_PATH/$OBJECT_NAME" "$ENCRYPTED_PATH" \
--config "$RCLONE_CONFIG" \
--s3-no-check-bucket \
--progress
log "Decrypting backup archive"
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-in "$ENCRYPTED_PATH" \
-out "$DECRYPTED_PATH" \
-pass env:BACKUP_ENCRYPTION_PASSPHRASE
log "Restoring decrypted backup archive"
DEPLOY_ROOT="$DEPLOY_ROOT" "$RESTORE_SCRIPT" "$DECRYPTED_PATH"
log "S3 restore completed from: $REMOTE_PATH/$OBJECT_NAME"

134
scripts/restore.sh Executable file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
cat <<'EOF'
Usage:
restore.sh <backup-archive.tar.gz>
Environment:
DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment.
RESTORE_SKIP_ENV Set to 1 to keep current .env files.
RESTORE_SKIP_MEDIA Set to 1 to keep current media files.
RESTORE_SKIP_DB Set to 1 to keep current database.
This script restores:
- deployment, backend, and frontend .env files
- media files into the backend media volume
- PostgreSQL database from database.sql
Database restore is destructive unless RESTORE_SKIP_DB=1 is set.
EOF
}
log() {
printf '[restore] %s\n' "$*"
}
fail() {
printf '[restore] %s\n' "$*" >&2
exit 1
}
require_file() {
local path="$1"
[[ -f "$path" ]] || fail "Required file not found: $path"
}
compose() {
docker compose -f "$DEPLOY_ROOT/docker-compose.yml" "$@"
}
wait_for_db() {
compose exec -T db sh -c '
set -eu
: "${POSTGRES_USER:?POSTGRES_USER is missing}"
: "${POSTGRES_DB:?POSTGRES_DB is missing}"
until pg_isready --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"; do
sleep 1
done
' >/dev/null
}
restore_env_if_present() {
local source_path="$1"
local target_path="$2"
if [[ -f "$source_path" ]]; then
mkdir -p "$(dirname "$target_path")"
cp "$source_path" "$target_path"
chmod 600 "$target_path" || true
log "Restored $target_path"
fi
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
ARCHIVE_PATH="${1:-}"
[[ -n "$ARCHIVE_PATH" ]] || {
usage
exit 1
}
DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/qlockify-deployment}"
BACKEND_ENV="$DEPLOY_ROOT/backend/qlockify-backend-deployment/.env"
FRONTEND_ENV="$DEPLOY_ROOT/frontend/qlockify-frontend-deployment/.env"
DEPLOY_ENV="$DEPLOY_ROOT/.env"
require_file "$ARCHIVE_PATH"
require_file "$DEPLOY_ROOT/docker-compose.yml"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
log "Extracting backup archive"
tar -C "$WORK_DIR" -xzf "$ARCHIVE_PATH"
if [[ "${RESTORE_SKIP_ENV:-0}" != "1" ]]; then
log "Restoring environment files"
restore_env_if_present "$WORK_DIR/env/deployment.env" "$DEPLOY_ENV"
restore_env_if_present "$WORK_DIR/env/backend.env" "$BACKEND_ENV"
restore_env_if_present "$WORK_DIR/env/frontend.env" "$FRONTEND_ENV"
else
log "Skipping environment restore"
fi
require_file "$BACKEND_ENV"
log "Checking Docker Compose configuration"
compose config -q
if [[ "${RESTORE_SKIP_MEDIA:-0}" != "1" ]]; then
require_file "$WORK_DIR/media.tar.gz"
log "Restoring media files into backend media volume"
compose run --rm --no-deps --entrypoint sh backend -c 'rm -rf /app/media/* /app/media/.[!.]* /app/media/..?* 2>/dev/null || true'
compose run --rm --no-deps --entrypoint sh -T backend -c 'mkdir -p /app/media && tar -C /app/media -xzf -' < "$WORK_DIR/media.tar.gz"
else
log "Skipping media restore"
fi
if [[ "${RESTORE_SKIP_DB:-0}" != "1" ]]; then
require_file "$WORK_DIR/database.sql"
log "Starting database service"
compose up -d db
wait_for_db
log "Recreating and restoring database"
compose exec -T db sh -c '
set -eu
: "${POSTGRES_USER:?POSTGRES_USER is missing}"
: "${POSTGRES_DB:?POSTGRES_DB is missing}"
psql --username="$POSTGRES_USER" --dbname=postgres --command="SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '\''$POSTGRES_DB'\'' AND pid <> pg_backend_pid();" >/dev/null
dropdb --username="$POSTGRES_USER" --if-exists "$POSTGRES_DB"
createdb --username="$POSTGRES_USER" "$POSTGRES_DB"
psql --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"
' < "$WORK_DIR/database.sql"
else
log "Skipping database restore"
fi
log "Restore completed"
log "Run: docker compose up -d --build"