- .woodpecker.yml: full pipeline template (install → lint-fix → lint → test → deploy) - scripts/setup-project.sh: one-command VPS setup (user, dir, deploy, sudoers, systemd, nginx) - scripts/deploy.sh: deploy script template (rsync + npm ci + systemctl + health check) - scripts/ci-lint-fix.sh: ESLint auto-fix with [CI SKIP] commit-back - docs/ci-cd.md: complete CI/CD documentation and troubleshooting - .env.example: added WOODPECKER_TOKEN - DOCS.md: added CI/CD section Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
266 lines
8.1 KiB
Bash
Executable File
266 lines
8.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# setup-project.sh — One-time VPS setup for a new project.
|
|
#
|
|
# Run as root on the VPS:
|
|
# bash setup-project.sh <project-name> <domain> [port]
|
|
#
|
|
# Example:
|
|
# bash setup-project.sh my-app app.spektr.design 3000
|
|
#
|
|
# What it does:
|
|
# 1. Creates service user (no login shell, no home dir)
|
|
# 2. Creates /opt/<project> directory
|
|
# 3. Installs deploy script to /usr/local/bin/
|
|
# 4. Adds sudoers rule for Woodpecker agent
|
|
# 5. Creates systemd service
|
|
# 6. Creates nginx config (location blocks for Webuzo)
|
|
#
|
|
# After running, you still need to:
|
|
# - Create .env file at /opt/<project>/.env
|
|
# - Set up SSL (certbot or Webuzo panel)
|
|
# - Push code to Gitea to trigger first deploy
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Args
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PROJECT="${1:?Usage: setup-project.sh <project-name> <domain> [port]}"
|
|
DOMAIN="${2:?Usage: setup-project.sh <project-name> <domain> [port]}"
|
|
PORT="${3:-3000}"
|
|
|
|
INSTALL_DIR="/opt/${PROJECT}"
|
|
SERVICE_USER="${PROJECT}"
|
|
DEPLOY_SCRIPT="/usr/local/bin/deploy-${PROJECT}"
|
|
|
|
# Detect Woodpecker agent user
|
|
AGENT_USER=$(ps -o user= -p "$(pgrep woodpecker-agent)" 2>/dev/null || echo "")
|
|
if [[ -z "${AGENT_USER}" ]]; then
|
|
echo "Warning: Woodpecker agent not running. Defaulting to 'git'."
|
|
AGENT_USER="git"
|
|
fi
|
|
|
|
echo "=== Setting up project: ${PROJECT} ==="
|
|
echo " Domain: ${DOMAIN}"
|
|
echo " Port: ${PORT}"
|
|
echo " Install dir: ${INSTALL_DIR}"
|
|
echo " User: ${SERVICE_USER}"
|
|
echo " Agent user: ${AGENT_USER}"
|
|
echo ""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if [[ $EUID -ne 0 ]]; then
|
|
echo "Error: run as root (sudo)"; exit 1
|
|
fi
|
|
|
|
if [[ -d "${INSTALL_DIR}" ]]; then
|
|
echo "Warning: ${INSTALL_DIR} already exists. Continuing..."
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Service user
|
|
# ---------------------------------------------------------------------------
|
|
|
|
echo "[1/6] Creating service user..."
|
|
if id "${SERVICE_USER}" &>/dev/null; then
|
|
echo " User ${SERVICE_USER} already exists"
|
|
else
|
|
useradd -r -s /sbin/nologin "${SERVICE_USER}"
|
|
echo " Created user ${SERVICE_USER}"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Project directory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
echo "[2/6] Creating project directory..."
|
|
mkdir -p "${INSTALL_DIR}"
|
|
chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
|
echo " ${INSTALL_DIR} ready"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Deploy script
|
|
# ---------------------------------------------------------------------------
|
|
|
|
echo "[3/6] Installing deploy script..."
|
|
cat > "${DEPLOY_SCRIPT}" << DEPLOY
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
INSTALL_DIR="${INSTALL_DIR}"
|
|
SERVICE_USER="${SERVICE_USER}"
|
|
SERVICE_NAME="${PROJECT}"
|
|
HEALTH_URL="http://localhost:${PORT}/api/health"
|
|
SOURCE_DIR="\${1:-.}"
|
|
echo "=== Deploy ${PROJECT} ==="
|
|
if [[ \$EUID -ne 0 ]]; then
|
|
echo "Error: must be root"; exit 1
|
|
fi
|
|
if [[ ! -d "\${INSTALL_DIR}" ]]; then
|
|
echo "Error: \${INSTALL_DIR} not found"; exit 1
|
|
fi
|
|
echo "[1/3] Syncing files..."
|
|
rsync -a --delete --exclude='node_modules' --exclude='.env' --exclude='.git' --exclude='.idea' "\${SOURCE_DIR}/" "\${INSTALL_DIR}/"
|
|
chown -R "\${SERVICE_USER}:\${SERVICE_USER}" "\${INSTALL_DIR}"
|
|
echo "[2/3] Installing dependencies..."
|
|
sudo -u "\${SERVICE_USER}" HOME="\${INSTALL_DIR}" bash -c "cd \${INSTALL_DIR} && npm ci --omit=dev"
|
|
echo "[3/3] Restarting service..."
|
|
systemctl daemon-reload
|
|
systemctl restart "\${SERVICE_NAME}"
|
|
sleep 2
|
|
if systemctl is-active --quiet "\${SERVICE_NAME}"; then
|
|
echo "Service running"
|
|
else
|
|
echo "Error: service failed"; exit 1
|
|
fi
|
|
curl -sf "\${HEALTH_URL}" || { echo "FAIL: health check"; exit 1; }
|
|
echo "=== Deploy complete ==="
|
|
DEPLOY
|
|
chmod 755 "${DEPLOY_SCRIPT}"
|
|
# Strip trailing whitespace (heredoc can add it)
|
|
sed -i 's/[[:space:]]*$//' "${DEPLOY_SCRIPT}"
|
|
echo " ${DEPLOY_SCRIPT} installed"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Sudoers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
echo "[4/6] Configuring sudoers..."
|
|
SUDOERS_FILE="/etc/sudoers.d/woodpecker-${PROJECT}"
|
|
echo "${AGENT_USER} ALL=(root) NOPASSWD: ${DEPLOY_SCRIPT} *" > "${SUDOERS_FILE}"
|
|
chmod 0440 "${SUDOERS_FILE}"
|
|
if visudo -cf "${SUDOERS_FILE}" &>/dev/null; then
|
|
echo " ${SUDOERS_FILE} — parsed OK"
|
|
else
|
|
echo " ERROR: sudoers syntax check failed!"
|
|
rm -f "${SUDOERS_FILE}"
|
|
exit 1
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Systemd service
|
|
# ---------------------------------------------------------------------------
|
|
|
|
echo "[5/6] Creating systemd service..."
|
|
cat > "/etc/systemd/system/${PROJECT}.service" << SERVICE
|
|
[Unit]
|
|
Description=${PROJECT}
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=${SERVICE_USER}
|
|
Group=${SERVICE_USER}
|
|
WorkingDirectory=${INSTALL_DIR}
|
|
ExecStart=/usr/bin/node ${INSTALL_DIR}/src/index.js
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
StartLimitBurst=5
|
|
StartLimitIntervalSec=60
|
|
|
|
EnvironmentFile=-${INSTALL_DIR}/.env
|
|
|
|
NoNewPrivileges=true
|
|
PrivateTmp=true
|
|
ProtectHome=true
|
|
ProtectKernelTunables=true
|
|
ProtectKernelModules=true
|
|
ProtectControlGroups=true
|
|
|
|
LimitNOFILE=65536
|
|
MemoryMax=256M
|
|
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
SyslogIdentifier=${PROJECT}
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
SERVICE
|
|
systemctl daemon-reload
|
|
echo " ${PROJECT}.service created"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Nginx config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
echo "[6/6] Creating nginx config..."
|
|
NGINX_DIR="/var/webuzo-data/nginx/custom/domains"
|
|
if [[ -d "${NGINX_DIR}" ]]; then
|
|
NGINX_CONF="${NGINX_DIR}/${DOMAIN}.conf"
|
|
else
|
|
NGINX_DIR="/etc/nginx/conf.d"
|
|
NGINX_CONF="${NGINX_DIR}/${PROJECT}.conf"
|
|
fi
|
|
|
|
cat > "${NGINX_CONF}" << 'NGINX'
|
|
# Static files
|
|
location / {
|
|
root __INSTALL_DIR__/public;
|
|
index index.html;
|
|
try_files $uri $uri/ /index.html;
|
|
|
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header X-Frame-Options "DENY" always;
|
|
add_header Referrer-Policy "no-referrer" always;
|
|
}
|
|
|
|
# API proxy
|
|
location ^~ /api/ {
|
|
proxy_pass http://127.0.0.1:__PORT__;
|
|
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;
|
|
proxy_connect_timeout 10s;
|
|
proxy_send_timeout 30s;
|
|
proxy_read_timeout 30s;
|
|
}
|
|
|
|
# WebSocket proxy (if needed)
|
|
location ^~ /ws/ {
|
|
proxy_pass http://127.0.0.1:__PORT__;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_pass_header Upgrade;
|
|
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;
|
|
proxy_connect_timeout 10s;
|
|
proxy_send_timeout 86400s;
|
|
proxy_read_timeout 86400s;
|
|
proxy_buffering off;
|
|
}
|
|
NGINX
|
|
|
|
sed -i "s|__INSTALL_DIR__|${INSTALL_DIR}|g" "${NGINX_CONF}"
|
|
sed -i "s|__PORT__|${PORT}|g" "${NGINX_CONF}"
|
|
|
|
if nginx -t 2>/dev/null; then
|
|
systemctl reload nginx
|
|
echo " ${NGINX_CONF} — nginx reloaded"
|
|
else
|
|
echo " Warning: nginx config test failed. Check ${NGINX_CONF} manually."
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Done
|
|
# ---------------------------------------------------------------------------
|
|
|
|
echo ""
|
|
echo "=== Setup complete ==="
|
|
echo ""
|
|
echo "Next steps:"
|
|
echo " 1. Create .env: nano ${INSTALL_DIR}/.env"
|
|
echo " 2. Push code to Gitea — Woodpecker will deploy automatically"
|
|
echo " 3. Check: curl -s http://localhost:${PORT}/api/health"
|
|
echo " 4. Uncomment deploy step in .woodpecker.yml:"
|
|
echo " sudo /usr/local/bin/deploy-${PROJECT} \"\${CI_WORKSPACE:-.}\""
|