Compare commits
21 Commits
3b052aeca4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| caf482e9f4 | |||
| 09952319e8 | |||
| 52ef680771 | |||
| 9f7accba04 | |||
| 863cbd9ec9 | |||
| af0ffb2293 | |||
| e015f01cd6 | |||
| e190825135 | |||
| 9a764fafb4 | |||
| 5ef7f18f77 | |||
| 40f89fc9aa | |||
| 4d82094c0b | |||
| 0a328156fa | |||
| a91606cc32 | |||
| 64be80da12 | |||
| 50eaf00cf2 | |||
| 34af725f41 | |||
| 596e2716ab | |||
| 8ab01bd5b7 | |||
| 8b6f25e068 | |||
| d3f14aeb78 |
@@ -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
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
85
.gitea/workflows/deploy.yml
Normal file
85
.gitea/workflows/deploy.yml
Normal 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
6
.gitignore
vendored
@@ -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
496
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
VITE_API_BASE_URL=http://localhost/api
|
VITE_API_BASE_URL=https://qlockify.ir
|
||||||
|
|||||||
@@ -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;"]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
admin:$2y$05$gSZ4s3BN8TsEc.pS/vaZi.v/AMrIozncWtFDGkNOglJlv59f7jc7i
|
Admin:$2y$10$YQpMsUAwPos59XKbWQRiSOxBJI4WmMbmTJBVy2ZRz/42FG.dj4W8K
|
||||||
|
|||||||
1
nginx/certs/.gitkeep
Normal file
1
nginx/certs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
213
scripts/backup-upload-s3.sh
Executable 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
126
scripts/backup.sh
Executable 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
92
scripts/deploy.sh
Normal 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
148
scripts/restore-from-s3.sh
Executable 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
134
scripts/restore.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user