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();