diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b429a4a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/bin/ +**/obj/ +.git/ +.gitea/ +.vs/ +.idea/ +.claude/ +*.md +docker-compose*.yml +deploy/ diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml new file mode 100644 index 0000000..c258494 --- /dev/null +++ b/.gitea/workflows/ci-cd.yml @@ -0,0 +1,126 @@ +name: CI/CD + +on: + push: { branches: [main] } + pull_request: { branches: [main] } + +concurrency: + group: hamkadr-cicd-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ---------------------------------------------------------------- CI + build: + name: "CI — dotnet build (Release)" + runs-on: ubuntu-latest + container: + image: mirror.soroushasadi.com/dotnet/sdk:10.0 + options: --add-host=gitea:host-gateway + steps: + - name: Checkout + env: + TOKEN: ${{ github.token }} + REF: ${{ github.ref }} + run: | + git init + git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" + git config http.extraheader "Authorization: Bearer ${TOKEN}" + git fetch --depth=1 origin "${REF}" + git checkout FETCH_HEAD + + - name: Write NuGet config (Nexus) + run: | + cat > /tmp/nuget.ci.config << 'EOF' + + + + + + + + EOF + + - name: Restore + run: dotnet restore src/JobsMedical.Web/JobsMedical.Web.csproj --configfile /tmp/nuget.ci.config + env: { DOTNET_CLI_TELEMETRY_OPTOUT: 1 } + + - name: Build + run: dotnet build src/JobsMedical.Web/JobsMedical.Web.csproj --no-restore -c Release + + # ---------------------------------------------------------------- Deploy + deploy: + name: "Deploy — hamkadr (compose)" + runs-on: self-hosted + needs: [build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + timeout-minutes: 40 + env: + # act host runner starts with a minimal PATH — extend so docker/snap resolve. + PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin + DOCKER_BUILDKIT: 1 + COMPOSE_DOCKER_CLI_BUILD: 1 + steps: + - name: Checkout + env: + TOKEN: ${{ github.token }} + REF: ${{ github.ref }} + run: | + git init + git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" + git config http.extraheader "Authorization: Bearer ${TOKEN}" + git fetch --depth=1 origin "${REF}" + git checkout FETCH_HEAD + + - name: Write .env + run: printf '%s' "$ENV_FILE" > .env + env: { ENV_FILE: ${{ secrets.ENV_FILE }} } + + - name: Back up database (if running) + run: | + set -a; . ./.env; set +a + if docker ps -a --format '{{.Names}}' | grep -q '^hamkadr-db$'; then + mkdir -p /opt/hamkadr-backups + TS=$(date +%Y%m%d-%H%M%S) + echo "Backing up DB → /opt/hamkadr-backups/hamkadr-$TS.sql" + docker exec hamkadr-db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \ + > "/opt/hamkadr-backups/hamkadr-$TS.sql" || echo "WARN: backup skipped (db not ready yet)" + else + echo "No existing db container — first deploy, nothing to back up." + fi + + - name: Tag current image for rollback + run: | + if docker image inspect hamkadr-app:latest >/dev/null 2>&1; then + docker tag hamkadr-app:latest hamkadr-app:rollback + echo "Tagged hamkadr-app:rollback" + fi + + - name: Build app image + run: docker compose -f docker-compose.prod.yml build app + + - name: Start database + run: docker compose -f docker-compose.prod.yml up -d --no-deps db + + - name: Recreate app (stop + rm + up — reliable across docker versions) + run: | + docker stop hamkadr-app 2>/dev/null || true + docker rm hamkadr-app 2>/dev/null || true + docker compose -f docker-compose.prod.yml up -d --no-deps app + + - name: Wait for app healthy + run: | + set -a; . ./.env; set +a + for i in $(seq 1 24); do + if curl -fsS "http://127.0.0.1:${APP_PORT}/healthz" >/dev/null 2>&1; then + echo "OK — hamkadr-app healthy on 127.0.0.1:${APP_PORT}"; exit 0 + fi + echo " [$i/24] not ready yet…" + sleep 5 + done + echo "TIMEOUT — dumping logs"; docker logs --tail=60 hamkadr-app; exit 1 + + - name: Prune dangling images + if: success() + run: docker image prune -f diff --git a/.gitignore b/.gitignore index 97a0019..f5391f3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ artifacts/ ## App appsettings.*.local.json +.env ## OS Thumbs.db diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..9b6dba8 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,108 @@ +# Deploying همکادر / hamkadr.ir + +CI/CD via the **soroush method**: push to Gitea → Gitea Actions builds (through the Nexus mirror) +and the self-hosted runner deploys with Docker Compose. nginx (already on the server) terminates +TLS for `hamkadr.ir` and reverse-proxies to the app. + +## Architecture & open ports + +``` +Internet ──443/80──► nginx (host, existing) ──► 127.0.0.1:8090 ──► hamkadr-app (container :8080) + │ internal docker net + ▼ + hamkadr-db (postgres, no host port) +``` + +| Port | Open? | Purpose | +|------|-------|---------| +| 22 | ✅ (ideally IP-restricted) | SSH | +| 80 | ✅ | HTTP → 443 redirect + Let's Encrypt ACME | +| 443 | ✅ | HTTPS `hamkadr.ir` | +| 8090 | ❌ host-localhost only | app, reached only by nginx | +| 5432 | ❌ internal docker net only | Postgres — never published | + +`ufw` should be exactly: `allow 22, 80, 443`. Nothing else. (80/443 are already open since nginx +serves git./mirror. — no firewall change needed.) + +## Files in this repo + +| File | Role | +|------|------| +| `Dockerfile` | multi-stage build, images + NuGet via `mirror.soroushasadi.com` | +| `nuget.docker.config` | NuGet → Nexus `nuget-group` | +| `docker-compose.prod.yml` | `app` (127.0.0.1:${APP_PORT}) + `db` (internal) + named volume | +| `.gitea/workflows/ci-cd.yml` | build job + self-hosted deploy (backup → rollback tag → recreate → health-wait) | +| `deploy/nginx-hamkadr.ir.conf` | nginx vhost for hamkadr.ir | + +## One-time setup + +### 1. DNS +A records → server IP: +``` +hamkadr.ir A +www.hamkadr.ir A +``` + +### 2. Gitea runner +Confirm the `act_runner` on this server has the **`self-hosted:host`** label (the deploy job needs it) +and its user is in the `docker` group. (Already true if other soroush projects deploy here.) + +### 3. ENV_FILE secret +Set at `https://git.soroushasadi.com/soroushdes/hamkadr/settings/secrets` → key **`ENV_FILE`**: + +```dotenv +ASPNETCORE_ENVIRONMENT=Production +ASPNETCORE_URLS=http://+:8080 + +# host port nginx proxies to (must match deploy/nginx-hamkadr.ir.conf) +APP_PORT=8090 + +# Postgres (container) — generate a strong password: openssl rand -hex 24 +POSTGRES_DB=hamkadr +POSTGRES_USER=hamkadr +POSTGRES_PASSWORD=__CHANGE_ME__ + +# EF Core connection string (host = compose service name "db") +ConnectionStrings__Default=Host=db;Port=5432;Database=hamkadr;Username=hamkadr;Password=__CHANGE_ME__ + +# Platform admin (the phone that gets the Admin role on login) +Auth__AdminPhone=09XXXXXXXXX + +# Future: Kavenegar / SMS.ir keys for real OTP delivery +``` +> `POSTGRES_PASSWORD` and the password in `ConnectionStrings__Default` must be identical. +> `ASPNETCORE_ENVIRONMENT=Production` ⇒ only **reference data** (roles/cities/districts) is seeded — +> no demo facilities/shifts. Real employers add listings via the employer panel. + +### 4. nginx vhost + TLS +```bash +sudo cp deploy/nginx-hamkadr.ir.conf /etc/nginx/sites-available/hamkadr.ir +sudo ln -s /etc/nginx/sites-available/hamkadr.ir /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +sudo certbot --nginx -d hamkadr.ir -d www.hamkadr.ir +``` + +### 5. First deploy +```bash +git push gitea main # add the gitea remote first if needed +``` +Watch `https://git.soroushasadi.com/soroushdes/hamkadr/actions`. The app auto-applies EF migrations +on startup and seeds reference data; nginx already proxies hamkadr.ir to it. + +## Operations + +- **Backups:** every deploy runs `pg_dump` → `/opt/hamkadr-backups/hamkadr-.sql` before touching containers. +- **Rollback:** the previous image is tagged `hamkadr-app:rollback` each deploy: + ```bash + docker stop hamkadr-app && docker rm hamkadr-app + docker run -d --name hamkadr-app --env-file .env --network hamkadr_default \ + -p 127.0.0.1:8090:8080 hamkadr-app:rollback + ``` +- **Rotate a secret:** edit `ENV_FILE` in Gitea, push any commit to redeploy. +- **Logs:** `docker logs -f hamkadr-app` +- **Restore a backup:** `cat /opt/hamkadr-backups/.sql | docker exec -i hamkadr-db psql -U hamkadr -d hamkadr` + +## Safety (never do these) +- ❌ `docker compose down -v` — deletes the database volume. +- ❌ bare `docker compose down` / `restart` — would stop other projects on the shared host. The + workflow always uses `--no-deps ` and explicit `stop`/`rm`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..305e40a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# Hamkadr (همکادر) — .NET 10 Razor Pages. Images + NuGet pulled through Nexus (mirror.soroushasadi.com). +FROM mirror.soroushasadi.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY nuget.docker.config /tmp/nuget.config +COPY src/ ./src/ +RUN dotnet restore src/JobsMedical.Web/JobsMedical.Web.csproj --configfile /tmp/nuget.config +RUN dotnet publish src/JobsMedical.Web/JobsMedical.Web.csproj -c Release -o /out --no-restore \ + /p:UseAppHost=false + +FROM mirror.soroushasadi.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /out ./ +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 \ + DOTNET_CLI_TELEMETRY_OPTOUT=1 +ENTRYPOINT ["dotnet", "JobsMedical.Web.dll"] diff --git a/deploy/nginx-hamkadr.ir.conf b/deploy/nginx-hamkadr.ir.conf new file mode 100644 index 0000000..7039514 --- /dev/null +++ b/deploy/nginx-hamkadr.ir.conf @@ -0,0 +1,25 @@ +# hamkadr.ir reverse-proxy vhost for the EXISTING nginx on the server. +# Install: +# sudo cp deploy/nginx-hamkadr.ir.conf /etc/nginx/sites-available/hamkadr.ir +# sudo ln -s /etc/nginx/sites-available/hamkadr.ir /etc/nginx/sites-enabled/ +# sudo nginx -t && sudo systemctl reload nginx +# sudo certbot --nginx -d hamkadr.ir -d www.hamkadr.ir # adds the :443 server + HTTP→HTTPS redirect +# +# APP_PORT below MUST match APP_PORT in the Gitea ENV_FILE secret (default 8090). + +server { + listen 80; + listen [::]:80; + server_name hamkadr.ir www.hamkadr.ir; + + # The app binds 127.0.0.1:8090 (docker-compose.prod.yml) — never exposed publicly. + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + 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; # app's ForwardedHeaders reads this → knows it's HTTPS + proxy_read_timeout 60s; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..f2b8cfb --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,39 @@ +# Production stack for hamkadr.ir — used by the Gitea deploy job (docker compose -f docker-compose.prod.yml). +# nginx (on the host) terminates TLS for hamkadr.ir and reverse-proxies to 127.0.0.1:${APP_PORT}. +name: hamkadr # pinned so redeploys reuse the same named volume (never creates orphaned data) + +services: + db: + image: mirror.soroushasadi.com/postgres:16-alpine + container_name: hamkadr-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - hamkadr_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 20 + # NOTE: no `ports:` — Postgres is reachable only by the app on the internal network. + + app: + build: + context: . + dockerfile: Dockerfile + image: hamkadr-app:latest + container_name: hamkadr-app + restart: unless-stopped + env_file: .env + depends_on: + db: + condition: service_healthy + ports: + - "127.0.0.1:${APP_PORT}:8080" # localhost-only; nginx proxies hamkadr.ir → here + +volumes: + hamkadr_db_data: + name: hamkadr_db_data diff --git a/nuget.docker.config b/nuget.docker.config new file mode 100644 index 0000000..32e0817 --- /dev/null +++ b/nuget.docker.config @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/JobsMedical.Web/Data/SeedData.cs b/src/JobsMedical.Web/Data/SeedData.cs index 8af0645..79f7d27 100644 --- a/src/JobsMedical.Web/Data/SeedData.cs +++ b/src/JobsMedical.Web/Data/SeedData.cs @@ -4,12 +4,14 @@ using Microsoft.EntityFrameworkCore; namespace JobsMedical.Web.Data; /// -/// Seeds a believable Tehran-focused board so the marketplace doesn't look empty on first run -/// (the cold-start problem). Idempotent: only seeds when the DB is empty. +/// Seeds reference data (cities, roles, districts) always, and a believable Tehran demo board +/// (facilities/shifts/jobs/raw listings) only when is true. +/// In production we pass false so real employers populate listings — no fake data goes public. +/// Idempotent: reference seeds only when empty; demo seeds only when no facilities exist. /// public static class SeedData { - public static async Task EnsureSeededAsync(AppDbContext db) + public static async Task EnsureSeededAsync(AppDbContext db, bool includeDemo = true) { if (await db.Cities.AnyAsync()) return; @@ -49,6 +51,9 @@ public static class SeedData new District { Name = "تجریش", CityId = tehran.Id }); await db.SaveChangesAsync(); + // ----- Demo data (Tehran sample board): development only ----- + if (!includeDemo) return; + var facilities = new[] { new Facility { Name = "بیمارستان میلاد", Type = FacilityType.Hospital, CityId = tehran.Id, diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index 57bca35..8115607 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -3,6 +3,7 @@ using System.Text.Unicode; using JobsMedical.Web.Data; using JobsMedical.Web.Services; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -45,7 +46,8 @@ using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); - await SeedData.EnsureSeededAsync(db); + // Production seeds reference data only (no demo facilities/shifts); dev seeds the full board. + await SeedData.EnsureSeededAsync(db, app.Environment.IsDevelopment()); } // Configure the HTTP request pipeline. @@ -56,6 +58,16 @@ if (!app.Environment.IsDevelopment()) app.UseHsts(); } +// Behind nginx (TLS terminated upstream): trust X-Forwarded-Proto/For so the app knows it's +// HTTPS — required for correct secure cookies and to avoid HTTPS-redirect loops. +var forwardedOptions = new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto +}; +forwardedOptions.KnownIPNetworks.Clear(); // only nginx can reach the container's bound port +forwardedOptions.KnownProxies.Clear(); +app.UseForwardedHeaders(forwardedOptions); + app.UseHttpsRedirection(); app.UseRouting(); @@ -70,4 +82,7 @@ app.MapStaticAssets(); app.MapRazorPages() .WithStaticAssets(); +// Lightweight liveness probe for the deploy health-wait loop (and uptime checks). +app.MapGet("/healthz", () => Results.Text("ok")); + app.Run();