Files
cv-site/doc/8-DEPLOYMENT.md
T
juanatsap 1f6f8e417e docs: Update skeleton loader implementation from hyperscript to JavaScript
MIGRATION SUMMARY:
- Moved skeleton loader logic from hyperscript to JavaScript (main.js)
- Changed from htmx:oobAfterSwap to htmx:afterSettle event
- Changed OOB swap from innerHTML to outerHTML for proper element replacement
- Added languageSwitching flag for state tracking
- Added 100ms delay after afterSettle for final render completion

DOCUMENTATION UPDATES:
- 2-MODERN-WEB-TECHNIQUES.md: Updated skeleton loader section with
2025-11-18 19:32:28 +00:00

21 KiB

Deployment Guide

Note: This is my personal CV website. While the code is open-source (MIT license), this deployment guide is primarily for my own use. The site may be modified without notice.

Complete guide for deploying the CV/Resume application to various platforms and environments.

Table of Contents


Introduction

This guide covers deploying the CV/Resume web application built with Go 1.25.1, HTMX, and chromedp for PDF generation. The application supports bilingual content (English/Spanish) and requires Chromium for PDF export functionality.

What this guide covers:

  • VPS deployment with systemd
  • Cloud platform deployment (Fly.io, Google Cloud Run, AWS)
  • Manual deployment
  • Configuration for different environments
  • Security hardening and production best practices
  • Monitoring, logging, and health checks
  • Zero-downtime updates and rollback procedures

Prerequisites

For All Deployment Methods

  • Go 1.25.1+ (if building from source)
  • Chromium/Chrome (for PDF generation via chromedp)
  • Git (to clone repository)
  • Basic command-line knowledge

For Specific Methods

  • Cloud Platforms: Account on chosen platform (Fly.io, GCP, AWS, etc.)
  • VPS: SSH access, sudo privileges, domain name (optional)
  • Production: SSL certificate (Let's Encrypt recommended)

System Requirements

  • CPU: 1 core minimum (2+ recommended)
  • RAM: 512MB minimum (1GB+ recommended for PDF generation)
  • Disk: 200MB for application + dependencies
  • Network: Port 1999 (default) or your chosen port

Quick Start

The fastest way to get started:

# Clone repository
git clone https://github.com/juanatsap/cv-site.git
cd cv-site

# Copy environment configuration
cp .env.example .env

# Build and run
make build
GO_ENV=production ./cv-server

# Verify deployment
curl http://localhost:1999/health

Access the application at http://localhost:1999


Deployment Methods

VPS Deployment

Traditional server deployment with systemd service management.

1. Systemd Service Setup

Prerequisites:

# Install Go 1.25.1
wget https://go.dev/dl/go1.25.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc

# Install Chromium
sudo apt update
sudo apt install -y chromium-browser curl git

# Verify installations
go version
chromium-browser --version

Deploy application:

# Clone repository
cd /home/txeo/Git/yo
git clone https://github.com/juanatsap/cv-site.git cv
cd cv

# Build production binary
make build

# Test binary
./cv-server --version
./cv-server  # Test locally, press Ctrl+C to stop

Install systemd service:

The project includes config/systemd/cv.service. Review and customize:

# Edit service file
nano config/systemd/cv.service

Ensure paths match your setup:

[Unit]
Description=CV Website Service
After=network.target
Wants=network-online.target

[Service]
Type=simple
User=txeo
Group=txeo
WorkingDirectory=/home/txeo/Git/yo/cv
ExecStart=/home/txeo/Git/yo/cv/cv-server

Environment="GO_ENV=production"
Environment="PORT=1999"

Restart=always
RestartSec=5
StartLimitInterval=60
StartLimitBurst=3

StandardOutput=append:/var/log/cv.log
StandardError=append:/var/log/cv.log
SyslogIdentifier=cv

[Install]
WantedBy=multi-user.target

Install and start:

# Create log file
sudo touch /var/log/cv.log
sudo chown txeo:txeo /var/log/cv.log

# Install service
sudo cp config/systemd/cv.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable cv
sudo systemctl start cv

# Check status
sudo systemctl status cv

# View logs
sudo journalctl -u cv -f
tail -f /var/log/cv.log

Manage service:

# Stop
sudo systemctl stop cv

# Restart
sudo systemctl restart cv

# Reload after config changes
sudo systemctl daemon-reload
sudo systemctl restart cv

# View logs
sudo journalctl -u cv --since "1 hour ago"

2. Nginx Reverse Proxy Configuration

Install Nginx:

sudo apt install -y nginx

Create Nginx configuration (/etc/nginx/sites-available/cv):

# Rate limiting zone
limit_req_zone $binary_remote_addr zone=cv_limit:10m rate=10r/s;

# Upstream backend
upstream cv_backend {
    server 127.0.0.1:1999 max_fails=3 fail_timeout=30s;
}

# HTTP → HTTPS redirect
server {
    listen 80;
    listen [::]:80;
    server_name your-domain.com;

    # ACME challenge for Let's Encrypt
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$server_name$request_uri;
    }
}

# HTTPS server
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name your-domain.com;

    # SSL configuration
    ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;

    # Static files caching
    location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
        proxy_pass http://cv_backend;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Main proxy
    location / {
        # Rate limiting
        limit_req zone=cv_limit burst=20 nodelay;

        proxy_pass http://cv_backend;
        proxy_http_version 1.1;

        # Headers
        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;

        # Timeouts
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;

        # Buffering
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
        proxy_busy_buffers_size 8k;
    }

    # Health check endpoint (no rate limit)
    location /health {
        proxy_pass http://cv_backend;
        access_log off;
    }

    # Access and error logs
    access_log /var/log/nginx/cv-access.log;
    error_log /var/log/nginx/cv-error.log;
}

Enable configuration:

# Enable site
sudo ln -s /etc/nginx/sites-available/cv /etc/nginx/sites-enabled/

# Test configuration
sudo nginx -t

# Reload Nginx
sudo systemctl reload nginx

3. SSL/TLS with Let's Encrypt

# Install Certbot
sudo apt install -y certbot python3-certbot-nginx

# Obtain certificate (Nginx plugin)
sudo certbot --nginx -d your-domain.com

# Or manual with webroot
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com

# Test auto-renewal
sudo certbot renew --dry-run

# Auto-renewal is configured via systemd timer
sudo systemctl status certbot.timer

Certificate renewal cron (if not using systemd timer):

# Add to crontab
sudo crontab -e

# Add line:
0 3 * * * certbot renew --quiet --post-hook "systemctl reload nginx"

4. Process Management

Makefile targets (already included):

# Install as service
make install-service

# Update service
make update-service

Manual management:

# Check if running
ps aux | grep cv-server

# Kill process
pkill cv-server

# Start with nohup (not recommended, use systemd)
nohup ./cv-server > /var/log/cv.log 2>&1 &

Cloud Platforms

Prerequisites: Install flyctl

Note: This requires building from source on Fly.io's infrastructure.

# Login
fly auth login

# Initialize app
fly launch --no-deploy

# Configure fly.toml
cat > fly.toml << 'EOF'
app = "your-cv-app-name"
primary_region = "mad"  # Madrid, Spain (choose your region)

[build]
  [build.args]
    GO_VERSION = "1.25.1"

[env]
  PORT = "8080"  # Fly.io uses 8080 internally
  GO_ENV = "production"
  TEMPLATE_HOT_RELOAD = "false"

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 1
  processes = ["app"]

[[vm]]
  cpu_kind = "shared"
  cpus = 1
  memory_mb = 512

[[services.http_checks]]
  interval = 30000
  grace_period = "10s"
  method = "get"
  path = "/health"
  protocol = "http"
  timeout = 5000
EOF

# Deploy
fly deploy

# Open in browser
fly open

# View logs
fly logs

# Scale
fly scale count 2

# SSH into VM
fly ssh console

Google Cloud Run

# Create a simple Dockerfile for Cloud Run
cat > Dockerfile.cloudrun << 'EOF'
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o cv-server .

FROM alpine:latest
RUN apk --no-cache add chromium ca-certificates
COPY --from=builder /app/cv-server /cv-server
COPY data /data
COPY templates /templates
COPY static /static
EXPOSE 8080
CMD ["/cv-server"]
EOF

# Build and push to Google Container Registry
gcloud builds submit --tag gcr.io/YOUR_PROJECT_ID/cv-server

# Deploy to Cloud Run
gcloud run deploy cv-server \
  --image gcr.io/YOUR_PROJECT_ID/cv-server \
  --platform managed \
  --region europe-west1 \
  --allow-unauthenticated \
  --port 8080 \
  --memory 512Mi \
  --cpu 1 \
  --min-instances 0 \
  --max-instances 10 \
  --set-env-vars GO_ENV=production,TEMPLATE_HOT_RELOAD=false,PORT=8080

# Get service URL
gcloud run services describe cv-server --region europe-west1 --format 'value(status.url)'

# View logs
gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=cv-server" --limit 50 --format json

AWS (EC2 or Lightsail)

For AWS deployment without containers, deploy directly to EC2 or Lightsail:

# SSH into your EC2/Lightsail instance
ssh -i your-key.pem ubuntu@your-instance-ip

# Follow VPS deployment steps above
# Install Go, Chromium, clone repo, build, and set up systemd

Railway / Render

These platforms can build from source using their build systems.

Railway:

  1. Connect GitHub repository
  2. Railway auto-detects Go project
  3. Set environment variables in dashboard:
    • PORT = 8080
    • GO_ENV = production
  4. Deploy automatically on git push

Render (create render.yaml):

services:
  - type: web
    name: cv-server
    env: go
    buildCommand: go build -o cv-server
    startCommand: ./cv-server
    envVars:
      - key: GO_ENV
        value: production
      - key: PORT
        value: 10000
    healthCheckPath: /health

Manual Deployment

Build and run without services.

1. Build from Source

# Clone repository
git clone https://github.com/juanatsap/cv-site.git
cd cv-site

# Install dependencies
go mod download
go mod verify

# Build production binary
make build

# Or manual build with optimizations
CGO_ENABLED=0 go build -ldflags="-s -w" -o cv-server .

# Verify binary
./cv-server --version
file cv-server  # Should show "statically linked"

2. Run as Standalone Binary

# Development mode
GO_ENV=development ./cv-server

# Production mode
GO_ENV=production PORT=1999 ./cv-server

# With custom configuration
PORT=8080 TEMPLATE_HOT_RELOAD=false ./cv-server

# Background with nohup
nohup ./cv-server > app.log 2>&1 &

# Stop background process
pkill cv-server

3. Environment Variables

Create .env file:

cp .env.example .env
nano .env

Edit:

PORT=1999
GO_ENV=production
TEMPLATE_HOT_RELOAD=false
READ_TIMEOUT=30
WRITE_TIMEOUT=30

Load and run:

# Load .env and run
export $(cat .env | xargs) && ./cv-server

Configuration

Environment Variables Reference

Variable Default Description Values
PORT 1999 Server port Any valid port (1-65535)
HOST localhost Bind address localhost, 0.0.0.0, IP address
GO_ENV development Environment mode development, production
TEMPLATE_HOT_RELOAD true Auto-reload templates true, false
TEMPLATE_DIR templates Templates directory Path to templates
PARTIALS_DIR templates/partials Partials directory Path to partials
DATA_DIR data CV data directory Path to JSON files
READ_TIMEOUT 15 Read timeout (seconds) Integer
WRITE_TIMEOUT 15 Write timeout (seconds) Integer
CHROME_BIN Auto-detected Chrome binary path Full path to chrome/chromium

.env File Setup

Development (.env):

PORT=1999
HOST=localhost
GO_ENV=development
TEMPLATE_HOT_RELOAD=true
READ_TIMEOUT=15
WRITE_TIMEOUT=15

Production (.env.production):

PORT=1999
HOST=0.0.0.0
GO_ENV=production
TEMPLATE_HOT_RELOAD=false
READ_TIMEOUT=30
WRITE_TIMEOUT=30
BASE_URL=https://your-domain.com
VERSION=1.0.0

Production vs Development Settings

Setting Development Production
GO_ENV development production
TEMPLATE_HOT_RELOAD true (fast iteration) false (performance)
HOST localhost (local only) 0.0.0.0 (external access)
READ_TIMEOUT 15s (relaxed) 30s (prevent slow clients)
WRITE_TIMEOUT 15s (relaxed) 30s (prevent slow responses)
Logging Verbose Structured JSON
Error Details Full stack traces Generic messages

Post-Deployment

Health Checks

Manual check:

# Basic health
curl http://localhost:1999/health

# With details
curl -s http://localhost:1999/health | jq .

# Expected response:
{
  "status": "healthy",
  "timestamp": "2025-11-09T12:00:00Z",
  "version": "1.0.0"
}

Automated monitoring:

# Simple monitoring script
cat > /usr/local/bin/cv-monitor.sh << 'EOF'
#!/bin/bash
HEALTH_URL="http://localhost:1999/health"
THRESHOLD=3
FAILURES=0

while true; do
  if ! curl -sf "$HEALTH_URL" > /dev/null; then
    FAILURES=$((FAILURES + 1))
    if [ $FAILURES -ge $THRESHOLD ]; then
      echo "Service unhealthy, restarting..."
      systemctl restart cv
      FAILURES=0
    fi
  else
    FAILURES=0
  fi
  sleep 30
done
EOF

chmod +x /usr/local/bin/cv-monitor.sh

Monitoring Setup

External monitoring (Uptime Robot, Pingdom, etc.):

  • URL: https://your-domain.com/health
  • Interval: 60 seconds
  • Expected status: 200
  • Expected response: "healthy"

Log Management

Systemd logs:

# View all logs
sudo journalctl -u cv

# Follow logs
sudo journalctl -u cv -f

# Last 100 lines
sudo journalctl -u cv -n 100

# Since timestamp
sudo journalctl -u cv --since "2025-11-09 12:00:00"

# Logs from last boot
sudo journalctl -u cv -b

Application logs:

# View log file
tail -f /var/log/cv.log

# Last 50 lines
tail -n 50 /var/log/cv.log

# Search for errors
grep -i error /var/log/cv.log

# Log rotation
sudo nano /etc/logrotate.d/cv

Logrotate configuration (/etc/logrotate.d/cv):

/var/log/cv.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0644 txeo txeo
    postrotate
        systemctl reload cv > /dev/null 2>&1 || true
    endscript
}

Backup Strategies

1. Application Backup:

# Backup script
cat > /usr/local/bin/cv-backup.sh << 'EOF'
#!/bin/bash
BACKUP_DIR="/backups/cv"
DATE=$(date +%Y%m%d_%H%M%S)

mkdir -p "$BACKUP_DIR"

# Backup application
tar -czf "$BACKUP_DIR/cv-app-$DATE.tar.gz" \
  /home/txeo/Git/yo/cv/data \
  /home/txeo/Git/yo/cv/templates \
  /home/txeo/Git/yo/cv/static \
  /home/txeo/Git/yo/cv/.env

# Keep only last 30 days
find "$BACKUP_DIR" -name "cv-app-*.tar.gz" -mtime +30 -delete

echo "Backup completed: $BACKUP_DIR/cv-app-$DATE.tar.gz"
EOF

chmod +x /usr/local/bin/cv-backup.sh

2. Automated daily backups:

# Add to crontab
crontab -e

# Daily at 2 AM
0 2 * * * /usr/local/bin/cv-backup.sh

Troubleshooting

Common Deployment Issues

1. Port Already in Use

Error: bind: address already in use

Solution:

# Find process using port 1999
sudo lsof -i :1999
sudo netstat -tulpn | grep 1999

# Kill process
sudo kill -9 <PID>

# Or change port
PORT=8080 ./cv-server

2. Chromium Not Found

Error: chrome not found or PDF generation fails

Solution:

# Install Chromium
# Ubuntu/Debian
sudo apt install -y chromium-browser

# Alpine
apk add --no-cache chromium

# Verify
which chromium-browser
chromium-browser --version

# Set environment variable
export CHROME_BIN=/usr/bin/chromium-browser

3. Permission Denied

Error: permission denied accessing files

Solution:

# Fix ownership
sudo chown -R txeo:txeo /home/txeo/Git/yo/cv

# Fix permissions
chmod +x cv-server
chmod -R 755 static templates data

# Check SELinux (if applicable)
sudo setenforce 0  # Temporary

4. Template Not Found

Error: template not found or rendering errors

Solution:

# Verify directory structure
ls -la templates/
ls -la data/

# Check environment variables
echo $TEMPLATE_DIR
echo $DATA_DIR

# Ensure working directory is correct
pwd
cd /home/txeo/Git/yo/cv

5. Health Check Fails

Error: Service marked unhealthy

Solution:

# Check service is running
curl http://localhost:1999/health

# Check logs
journalctl -u cv -n 50

# Verify port is accessible
nc -zv localhost 1999

Performance Tuning

1. Go Runtime Tuning:

# Increase max connections
export GOMAXPROCS=4

# Adjust garbage collection
export GOGC=100  # Default, lower = more frequent GC

2. Nginx Tuning (/etc/nginx/nginx.conf):

worker_processes auto;
worker_rlimit_nofile 65535;

events {
    worker_connections 4096;
    use epoll;
    multi_accept on;
}

http {
    # Connection limits
    keepalive_timeout 65;
    keepalive_requests 100;

    # Buffer sizes
    client_body_buffer_size 128k;
    client_max_body_size 10m;
    client_header_buffer_size 1k;
    large_client_header_buffers 4 8k;
}

3. System Limits (/etc/security/limits.conf):

txeo soft nofile 65536
txeo hard nofile 65536

Security Hardening

1. Firewall Configuration:

# UFW (Ubuntu)
sudo ufw allow 22/tcp      # SSH
sudo ufw allow 80/tcp      # HTTP
sudo ufw allow 443/tcp     # HTTPS
sudo ufw enable

# Block direct access to app port
sudo ufw deny 1999/tcp

2. Fail2Ban for Nginx:

# Install
sudo apt install -y fail2ban

# Configure
sudo nano /etc/fail2ban/jail.local

Add:

[nginx-http-auth]
enabled = true

[nginx-limit-req]
enabled = true
filter = nginx-limit-req
logpath = /var/log/nginx/cv-error.log

Updates & Maintenance

Rolling Updates

Systemd Service:

# Pull latest code
cd /home/txeo/Git/yo/cv
git pull origin main

# Build new binary
make build

# Test binary
./cv-server --version

# Restart service (brief downtime)
sudo systemctl restart cv

# Verify
curl http://localhost:1999/health

Zero-Downtime Deployment

Nginx + Multiple Backends:

  1. Run two instances:
# Start second instance on different port
PORT=1998 ./cv-server &

# Update Nginx upstream
upstream cv_backend {
    server 127.0.0.1:1999 weight=1;
    server 127.0.0.1:1998 weight=1;
}

# Reload Nginx
sudo systemctl reload nginx
  1. Deploy new version:
# Stop first instance
pkill -f "PORT=1999"

# Start updated instance
PORT=1999 ./cv-server-new &

# Verify health
curl http://localhost:1999/health

# Stop second instance
pkill -f "PORT=1998"

Rollback Procedures

Manual rollback:

# Git rollback
cd /home/txeo/Git/yo/cv
git log --oneline -10  # Find commit hash
git checkout abc123

# Rebuild and restart
make build
sudo systemctl restart cv

# Verify
curl http://localhost:1999/health

Best Practices Summary

  1. Use systemd for reliable process management
  2. Enable health checks for monitoring
  3. Set up monitoring to detect issues early
  4. Configure SSL/TLS for production (Let's Encrypt is free)
  5. Use reverse proxy (Nginx) for security and performance
  6. Implement log rotation to prevent disk space issues
  7. Automate backups of data, templates, and configuration
  8. Test rollback procedures before production deployments
  9. Use environment variables for configuration (never hardcode)
  10. Enable rate limiting to prevent abuse

For customization, see 7-CUSTOMIZATION.md