Files
dyolink/infrastructure/DEPLOY.md
rameen 19364ad5c2
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 42s
Production — tag build, push, deploy / deploy (push) Successful in 21s
resolve failed handshake connection with https://wixur.ir:3000
2026-08-31 10:04:30 +03:30

14 KiB
Raw Permalink Blame History

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

  1. 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).

  1. .env in /opt/dyolink/infrastructure/ — from deploy.prod.env.example:
  • DOMAIN=nudentic.ir
  • REGISTRY_PREFIX=wixur.ir:3000/<gitea-owner> (same owner as Gitea REGISTRY_OWNER)
  • TAG=v1.0.1 (CI overrides per release)
  1. secrets/backend.env: FRONTEND_URL=https://nudentic.ir, COOKIE_SECURE=true

  2. SSH — user that can run docker (e.g. dyolink in the docker group). Put the matching public key in ~/.ssh/authorized_keys.

  3. 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:latest
  • dyolink/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:

  1. Issues Let's Encrypt certificate (first run)
  2. Renders HTTPS nginx config
  3. Pulls images from Docker Hub
  4. Starts all containers

First deploy takes 35 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.env configured (real passwords + JWT)
  • ./scripts/deploy-prod.sh completed
  • curl https://DOMAIN/api/health returns {"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 setupdocker 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.