14 KiB
Dyolink — Production Server Deploy Guide
Linux VPS serves https://nudentic.ir only. Images come from the Gitea registry (wixur.ir:3000) on git tags. Staging is Windows (https://wixur.ir) — see STAGING-DEPLOY.md.
Automated path: tag v1.0.1 → .gitea/workflows/prod-tag-deploy.yml (Windows runner builds, SSH to this server).
Manual / first SSL: Docker Hub + scripts/build-and-push-prod.sh is still documented below as a fallback.
Example host: https://nudentic.ir (do not point wixur.ir at this VPS).
Automated deploy (Gitea tags)
git tag v1.0.1 && git push origin v1.0.1
↓
Windows act_runner builds frontend with https://nudentic.ir
↓
Push wixur.ir:3000/<owner>/dyolink-*:v1.0.1 (never :latest)
↓
SSH → Linux docker login wixur.ir:3000 → compose pull + up
↓
https://nudentic.ir
:latest is staging only (wixur.ir baked in). Production compose must pin TAG=v1.0.1.
One-time on the Linux server
- HTTP registry — Gitea is
http://wixur.ir:3000. In/etc/docker/daemon.json:
{
"insecure-registries": ["wixur.ir:3000"]
}
Then sudo systemctl restart docker (containers restart).
.envin/opt/dyolink/infrastructure/— fromdeploy.prod.env.example:
DOMAIN=nudentic.irREGISTRY_PREFIX=wixur.ir:3000/<gitea-owner>(same owner as GiteaREGISTRY_OWNER)TAG=v1.0.1(CI overrides per release)
-
secrets/backend.env:FRONTEND_URL=https://nudentic.ir,COOKIE_SECURE=true -
SSH — user that can run
docker(e.g.dyolinkin thedockergroup). Put the matching public key in~/.ssh/authorized_keys. -
Test pull (after a staging or prod image exists):
docker login wixur.ir:3000 -u <gitea-user>
docker pull wixur.ir:3000/<owner>/dyolink-backend:<sha-or-tag>
Gitea (same repo as staging)
Variables: PROD_PUBLIC_BASE_URL=https://nudentic.ir, PROD_REGISTRY_HOST=wixur.ir:3000, PROD_INFRA_DIR=/opt/dyolink/infrastructure, plus existing REGISTRY_HOST / REGISTRY_OWNER / CLONE_HOST.
Secrets: PROD_SSH_HOST, PROD_SSH_USER, PROD_SSH_KEY (private key). Reuse REGISTRY_USERNAME / REGISTRY_PASSWORD. Optional PROD_SSH_PORT (default 22).
OpenSSH must be on the Windows runner (ssh.exe / scp.exe).
Release
git tag v1.0.1
git push origin v1.0.1
Or Gitea → Actions → Production — tag build, push, deploy → Run → tag v1.0.1.
Check https://nudentic.ir/api/health.
Architecture
Internet → Nginx (:80 / :443)
├── / → frontend:3000 (Next.js)
└── /api → backend:3000 (NestJS)
└── postgres:5432
| Service | Image | Notes |
|---|---|---|
| postgres | postgres:15-alpine |
Data in Docker volume |
| backend | REGISTRY_PREFIX/dyolink-backend:TAG |
Gitea v* or Hub dyolink/… |
| frontend | REGISTRY_PREFIX/dyolink-frontend:TAG |
URLs baked in at build time |
| nginx | nginx:alpine |
SSL termination + reverse proxy |
| certbot | certbot/certbot |
Auto-renews certificates |
Prerequisites
On your Mac (build machine)
- Docker Desktop running
- Repo cloned
- Docker Hub account (
dyolink) with images pushed
On the server
- Ubuntu 24.04 (or similar)
- Root or sudo access
- Domain with DNS A record → server public IP
- Ports 22, 80, 443 open (UFW + cloud provider firewall)
Part 1 — Build & push images (Mac)
Frontend URLs are compiled into the image. Always build with the real public domain:
cd /path/to/dyolink
docker login # only needed on Mac to push
./infrastructure/scripts/build-and-push-prod.sh YOUR_DOMAIN.com latest
Example:
./infrastructure/scripts/build-and-push-prod.sh wixur.ir latest
This pushes:
dyolink/dyolink-backend:latestdyolink/dyolink-frontend:latest
When to rebuild: domain changes, frontend env (NEXT_PUBLIC_*) changes, or new app release.
Part 2 — Server bootstrap (once per server)
SSH as root:
ssh root@YOUR_SERVER_IP
2.1 Update system & create deploy user
apt update && apt upgrade -y
apt install -y curl git ufw fail2ban
adduser dyolink
usermod -aG sudo dyolink
# Optional: copy SSH keys from root
mkdir -p /home/dyolink/.ssh
cp /root/.ssh/authorized_keys /home/dyolink/.ssh/ 2>/dev/null || true
chown -R dyolink:dyolink /home/dyolink/.ssh
chmod 700 /home/dyolink/.ssh
2.2 Install Docker
If curl -fsSL https://get.docker.com | sh returns 403, use Ubuntu packages:
apt update
apt install -y docker.io docker-compose-v2
systemctl enable --now docker
usermod -aG docker dyolink
Verify:
docker --version
docker compose version
2.3 Docker Hub login (if images are private)
docker login
Public images skip this step.
2.4 Firewall
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
Also open 80 and 443 in your VPS provider's cloud firewall panel if one exists.
2.5 DNS
Before SSL, confirm DNS:
dig +short YOUR_DOMAIN.com
# Must return YOUR_SERVER_IP
Part 3 — Copy infrastructure to server (Mac)
cd /path/to/dyolink
ssh dyolink@YOUR_SERVER_IP "sudo mkdir -p /opt/dyolink/secrets && sudo chown -R dyolink:dyolink /opt/dyolink"
scp -r infrastructure/docker-compose.prod.yml \
infrastructure/nginx \
infrastructure/scripts \
infrastructure/database \
infrastructure/deploy.prod.env.example \
infrastructure/backend.prod.env.example \
infrastructure/database.prod.env.example \
dyolink@YOUR_SERVER_IP:/opt/dyolink/infrastructure/
On the server:
ssh dyolink@YOUR_SERVER_IP
chmod +x /opt/dyolink/infrastructure/scripts/*.sh
Part 4 — Configure secrets (server)
4.1 Main .env
cd /opt/dyolink/infrastructure
cp deploy.prod.env.example .env
nano .env
DOMAIN=wixur.ir
DOCKER_USERNAME=dyolink
TAG=latest
LETSENCRYPT_EMAIL=your-email@example.com
DEPLOY_SECRETS_DIR=/opt/dyolink/secrets
4.2 Database secrets
cp database.prod.env.example /opt/dyolink/secrets/database.env
nano /opt/dyolink/secrets/database.env
POSTGRES_USER=dyolink_user
POSTGRES_PASSWORD=STRONG_PASSWORD_HERE
POSTGRES_DB=dyolink_db
4.3 Backend secrets
cp backend.prod.env.example /opt/dyolink/secrets/backend.env
nano /opt/dyolink/secrets/backend.env
Generate JWT secrets:
openssl rand -hex 32 # use for JWT_SECRET
openssl rand -hex 32 # use for JWT_REFRESH_SECRET (must be different)
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://dyolink_user:STRONG_PASSWORD_HERE@postgres:5432/dyolink_db
JWT_SECRET=<first openssl output>
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=<second openssl output>
JWT_REFRESH_EXPIRES_IN=30d
FRONTEND_URL=https://wixur.ir
SMS_IR_API_KEY=your_key
SMS_IR_TEMPLATE_ID=your_template_id
Critical checks:
| Rule | Why |
|---|---|
DATABASE_URL password = POSTGRES_PASSWORD |
Backend cannot connect otherwise |
FRONTEND_URL = https://YOUR_DOMAIN |
CORS, cookies, invite links |
JWT secrets must not contain CHANGE_ME |
App refuses to start (by design) |
Postgres password set before first up |
Password only applied on first volume create |
Part 5 — Deploy (server)
cd /opt/dyolink/infrastructure
./scripts/deploy-prod.sh
This script:
- Issues Let's Encrypt certificate (first run)
- Renders HTTPS nginx config
- Pulls images from Docker Hub
- Starts all containers
First deploy takes 3–5 minutes.
Part 6 — Verify
docker compose -f docker-compose.prod.yml --env-file .env ps
Expected:
| Container | Status |
|---|---|
| dyolink_db_prod | Up (healthy) |
| dyolink_backend_prod | Up (healthy) |
| dyolink_frontend_prod | Up |
| dyolink_nginx_prod | Up |
| dyolink_certbot_prod | Up |
curl -s https://YOUR_DOMAIN/api/health
# {"status":"ok","timestamp":"..."}
curl -I https://YOUR_DOMAIN/
# HTTP/2 200
Open https://YOUR_DOMAIN in a browser.
Updating the app (new release)
Preferred: git tag vX.Y.Z && git push origin vX.Y.Z (Gitea production workflow).
Fallback (Docker Hub from a Mac):
./infrastructure/scripts/build-and-push-prod.sh nudentic.ir v1.0.1
On server (if not using CI):
cd /opt/dyolink/infrastructure
# TAG in .env or: TAG=v1.0.1 docker compose -f docker-compose.prod.yml --env-file .env pull backend frontend
docker compose -f docker-compose.prod.yml --env-file .env pull backend frontend
docker compose -f docker-compose.prod.yml --env-file .env up -d
Backend runs prisma migrate deploy automatically on container start.
Troubleshooting
Docker install: get.docker.com returns 403
Use apt install docker.io docker-compose-v2 (see Part 2.2).
Docker Hub pull: 403 Forbidden
docker login
If still blocked, transfer images from Mac:
# Mac
docker save dyolink/dyolink-backend:latest dyolink/dyolink-frontend:latest \
postgres:15-alpine nginx:alpine certbot/certbot:latest | gzip > images.tar.gz
scp images.tar.gz dyolink@SERVER:/tmp/
# Server
gunzip -c /tmp/images.tar.gz | docker load
Backend crash: JWT_SECRET must be changed from the placeholder value
Edit /opt/dyolink/secrets/backend.env — replace JWT secrets with openssl rand -hex 32 output. Restart:
docker compose -f docker-compose.prod.yml --env-file .env up -d backend
HTTP shows "obtaining SSL certificate" / HTTPS fails
Nginx is still on the bootstrap config. Fix:
cd /opt/dyolink/infrastructure
./scripts/render-nginx-ssl.sh
docker compose -f docker-compose.prod.yml --env-file .env up -d nginx --force-recreate
curl -s https://YOUR_DOMAIN/api/health
Backend Restarting — database password mismatch
If you changed POSTGRES_PASSWORD after the first deploy, reset the DB volume (destroys data):
docker compose -f docker-compose.prod.yml --env-file .env down
docker volume rm dyolink_postgres_data_prod
# Fix database.env + backend.env passwords to match
./scripts/deploy-prod.sh
Docker login to wixur.ir:3000: http response to HTTPS client
Add "insecure-registries": ["wixur.ir:3000"] to /etc/docker/daemon.json and restart Docker.
connection reset by peer while pulling :v* from Gitea
docker login succeeded, then compose pull failed on a frontend/backend manifest or layer from wixur.ir:3000. Auth is fine — the Windows Gitea registry dropped the TCP connection (common right after a large push, or when both images pull in parallel).
Images are already in the registry. Re-run only the deploy job, or on the VPS:
cd /opt/dyolink/infrastructure
./scripts/prod-remote-deploy.sh v1.0.2
prod-remote-deploy.sh pulls backend then frontend with retries. If every attempt RSTs, check Gitea is up and insecure-registries includes wixur.ir:3000.
View logs
docker compose -f docker-compose.prod.yml --env-file .env logs backend --tail 50
docker compose -f docker-compose.prod.yml --env-file .env logs nginx --tail 50
docker compose -f docker-compose.prod.yml --env-file .env logs frontend --tail 50
Internal health checks (bypass public network)
docker compose -f docker-compose.prod.yml --env-file .env exec nginx \
wget -qO- http://backend:3000/api/health
docker compose -f docker-compose.prod.yml --env-file .env exec frontend \
wget -qO- http://127.0.0.1:3000/ | head -3
File reference
| Path on server | Purpose |
|---|---|
/opt/dyolink/infrastructure/.env |
DOMAIN, REGISTRY_PREFIX, TAG, Let's Encrypt email |
/opt/dyolink/secrets/database.env |
Postgres credentials |
/opt/dyolink/secrets/backend.env |
API secrets, DATABASE_URL, JWT, SMS, FRONTEND_URL |
/opt/dyolink/infrastructure/nginx/generated/default.conf |
Auto-generated nginx SSL config |
/opt/dyolink/infrastructure/scripts/prod-remote-deploy.sh |
Tag deploy (CI SSH) |
/opt/dyolink/infrastructure/scripts/deploy-prod.sh |
First-time SSL + stack up |
.gitea/workflows/prod-tag-deploy.yml |
CI: build, push :v*, SSH deploy |
Quick checklist (new server)
- DNS A record → server IP
- Docker installed on server
- UFW + cloud firewall: 22, 80, 443 open
- Images built with correct domain and pushed to Docker Hub
infrastructure/copied to/opt/dyolink/.env,database.env,backend.envconfigured (real passwords + JWT)./scripts/deploy-prod.shcompletedcurl https://DOMAIN/api/healthreturns{"status":"ok",...}- App loads in browser
Issues encountered on first deploy (wixur.ir) — summary
| Problem | Cause | Type |
|---|---|---|
get.docker.com 403 |
Regional/network block | Server setup — use apt install docker.io |
| Docker Hub pull 403 | Hub blocked without login | Server setup — docker login |
| Backend crash loop | JWT_SECRET still had CHANGE_ME |
Config — edit backend.env |
| HTTP "obtaining SSL" / HTTPS broken | Nginx not recreated after SSL config | Deploy script — fixed in init-letsencrypt.sh / deploy-prod.sh |
Frontend "unhealthy" in docker ps |
Healthcheck timing; app still served pages | Cosmetic — no action needed |
No application code changes were required. The app, migrations, and seed all worked on first deploy once config was correct.