diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bbaf25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +local/ +dist/ + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..4ee4ca4 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,52 @@ +stages: + - build + - deploy + +variables: + IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA + IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest + +build: + stage: build + image: docker:26 + services: + - docker:26-dind + before_script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + script: + - docker pull $IMAGE_LATEST || true + - docker build --pull --cache-from $IMAGE_LATEST --tag $IMAGE --tag $IMAGE_LATEST . + - docker push $IMAGE + - docker push $IMAGE_LATEST + only: + - main + +deploy: + stage: deploy + image: alpine:3.21 + before_script: + - apk add --no-cache openssh-client + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh && chmod 700 ~/.ssh + - echo "$SSH_HOST_KEY" >> ~/.ssh/known_hosts + script: + - | + ssh $SSH_USER@$SSH_HOST " + docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY && + docker pull $IMAGE_LATEST && + docker stop arcline-docs 2>/dev/null || true && + docker rm arcline-docs 2>/dev/null || true && + docker run -d \ + --name arcline-docs \ + --restart unless-stopped \ + -p 8080:8080 \ + -v /opt/arcline-docs/.env:/app/.env:ro \ + -v /opt/arcline-docs/data:/app/data \ + $IMAGE_LATEST + " + only: + - main + needs: + - build + diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..1f90590 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,715 @@ +# Deployment Guide + +> **From source to production** — how every Arcline service is deployed, +> managed, and maintained. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Production Environment](#production-environment) +- [Deployment Methods](#deployment-methods) +- [Service Directory Layout](#service-directory-layout) +- [User & Permissions Model](#user--permissions-model) +- [Systemd Service Units](#systemd-service-units) +- [Nginx Reverse Proxy](#nginx-reverse-proxy) +- [Deploy by Service](#deploy-by-service) +- [Database Management](#database-management) +- [Backup & Restore](#backup--restore) +- [Rollback Procedures](#rollback-procedures) +- [Health Checks](#health-checks) +- [Troubleshooting](#troubleshooting) + +--- + +## Overview + +Every Arcline service deploys as a single static Go binary. The deployment +strategy depends on the service type: + +| Type | Method | Examples | +|------|--------|---------| +| **Web service** | Docker container | portal, billing, docs | +| **Native binary** | Direct install on host | website (OpenBSD), uptime | +| **CLI tool** | Install to `$PATH` | check, audit, dns, migrate, vault | + +All services share the same core deployment flow: + +1. **Build** — Compile static Go binary with `CGO_ENABLED=0` +2. **Package** — Containerize (Docker) or ship raw binary +3. **Deploy** — Transfer to production host and start +4. **Verify** — Health check endpoint or process monitoring + +--- + +## Production Environment + +### Host Specifications + +| Host | Role | OS | Location | +|------|------|----|----------| +| `srv01` | Primary web server | OpenBSD | Arcline datacenter | +| `srv02` | Application server | Linux (Alpine) | Arcline datacenter | +| `srv03` | Database & storage | Linux (Alpine) | Arcline datacenter | + +### Network + +- All services listen on **127.0.0.1** (localhost) only +- Nginx handles TLS termination and reverse proxy to local services +- Public access is through pfSense port forwarding (443 → Nginx) + +### Runtime Dependencies + +- **Docker** (for containerized services): `docker-ce` +- **Nginx**: `nginx` (OpenBSD: `nginx` package) +- **SQLite**: No runtime dependency (pure Go, file-based) +- **ca-certificates**: Required in all containers for TLS + +--- + +## Deployment Methods + +### Method 1: Docker Deployment (Web Services) + +Used for: portal, billing, docs (dynamic), email (future) + +```bash +# ── On production host ──────────────────────────────────────────────── + +# 1. Pull the latest image +docker login -u -p +docker pull /:latest + +# 2. Stop and remove existing container +docker stop 2>/dev/null || true +docker rm 2>/dev/null || true + +# 3. Start new container +docker run -d \ + --name \ + --restart unless-stopped \ + -p 127.0.0.1:: \ + -v /opt//.env:/app/.env:ro \ + -v /opt//data:/app/data \ + /:latest + +# 4. Verify +docker ps | grep +docker logs --tail 20 +``` + +### Method 2: Native Binary (OpenBSD) + +Used for: website + +```bash +# ── On build machine ────────────────────────────────────────────────── + +# Cross-compile +GOOS=openbsd GOARCH=amd64 go build -ldflags="-s -w" -o binary-openbsd-amd64 . + +# Deploy +scp binary-openbsd-amd64 srv01:/usr/local/bin/ +rsync -av --delete static/ srv01:/var/www//static/ + +# ── On production host ──────────────────────────────────────────────── + +# Restart service +doas rcctl restart +``` + +### Method 3: Static Binary (CLI Tools) + +Used for: uptime, status, check, audit, dns, migrate, vault + +```bash +# ── On build machine ────────────────────────────────────────────────── + +# Cross-compile for the target architecture +GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o binary-linux-amd64 . + +# Deploy +scp binary-linux-amd64 srv02:/usr/local/bin/ +ssh srv02 "chmod 0755 /usr/local/bin/" + +# Verify +ssh srv02 " version" +``` + +--- + +## Service Directory Layout + +### Containerized Services + +``` +/opt/arcline-/ +├── .env # Environment variables (arcline:arcline, 0640) +└── data/ # Persistent data directory + └── .db # SQLite database (created at runtime) +``` + +### Native Services (OpenBSD) + +``` +/usr/local/bin/ +├── arcline-web # Go binary +└── rc.d/ # rc.d scripts (managed by rcctl) + +/var/www/arclineit/ +├── static/ # CSS, JS, images +├── templates/ # Go HTML templates +└── .env # Environment config +``` + +### CLI Tools + +``` +/usr/local/bin/ +├── arcline-uptime +├── arcline-status +├── arcline-check +├── arcline-audit +├── arcline-dns +├── arcline-migrate +├── arcline-vault +└── arcline-email + +/etc/arcline/ +├── uptime.yaml # Uptime monitor config +├── status.yaml # Status page config +├── status.d/ # Modular status config directory +└── email.toml # Email server config + +/var/lib/arcline/ +├── uptime.db # Uptime monitoring database +└── vault/ # Vault secrets storage +``` + +--- + +## User & Permissions Model + +### System User + +```bash +# Create the arcline user (done once per host) +addgroup -S arcline +adduser -S -G arcline -h /opt arcline +``` + +### Directory Permissions + +```bash +# Application directory +chown -R arcline:arcline /opt/arcline- +chmod 0750 /opt/arcline- + +# Environment file (sensitive!) +chown arcline:arcline /opt/arcline-/.env +chmod 0640 /opt/arcline-/.env + +# Database file (created by app, but ensure permissions) +chown arcline:arcline /opt/arcline-/data/*.db +chmod 0640 /opt/arcline-/data/*.db +``` + +### Docker Container User + +All Docker containers run as the `arcline` non-root user: + +```dockerfile +RUN addgroup -S arcline && adduser -S -G arcline arcline +USER arcline +``` + +--- + +## Systemd Service Units + +### Template: `arcline-.service` + +```ini +[Unit] +Description=Arcline +After=network.target + +[Service] +Type=simple +User=arcline +Group=arcline +WorkingDirectory=/opt/arcline- +EnvironmentFile=/opt/arcline-/.env +ExecStart=/opt/arcline-/ [flags] +Restart=on-failure +RestartSec=5s + +# Hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/opt/arcline- + +[Install] +WantedBy=multi-user.target +``` + +### Service Management + +```bash +# Install the unit file +sudo cp arcline-.service /etc/systemd/system/ +sudo systemctl daemon-reload + +# Enable on boot +sudo systemctl enable arcline- + +# Start / stop / restart / status +sudo systemctl start arcline- +sudo systemctl stop arcline- +sudo systemctl restart arcline- +sudo systemctl status arcline- + +# View logs +sudo journalctl -u arcline- -f +``` + +### Hardening Options + +| Option | Purpose | +|--------|---------| +| `NoNewPrivileges=yes` | Prevent privilege escalation via `suid` binaries | +| `PrivateTmp=yes` | Isolated `/tmp` for the service | +| `ProtectSystem=strict` | Read-only `/usr` and `/etc` | +| `ProtectHome=yes` | No access to `/home`, `/root` | +| `ReadWritePaths=...` | Only allow writes to the data directory | + +**Reference implementation:** `portal/deploy/arcline-portal.service` + +--- + +## Nginx Reverse Proxy + +### Template: `nginx-.conf` + +```nginx +# HTTP → HTTPS redirect +server { + listen 80; + server_name ; + return 301 https://$host$request_uri; +} + +# HTTPS server +server { + listen 443 ssl; + server_name ; + + ssl_certificate /etc/ssl//fullchain.pem; + ssl_certificate_key /etc/ssl//privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Security headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + + location / { + proxy_pass http://127.0.0.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; + proxy_read_timeout 30s; + } +} +``` + +### SSL Certificate Management + +Certificates from **Let's Encrypt** (certbot) or **Step CA** (internal). + +```bash +# External (Let's Encrypt) +certbot certonly --webroot -w /var/www/acme -d + +# Internal (Step CA) +step certificate install /etc/step-ca/certs/intermediate_ca.crt +``` + +**Reference implementation:** `portal/deploy/nginx-portal.conf` + +--- + +## Deploy by Service + +### website — `arcline.it` + +| Detail | Value | +|--------|-------| +| **Method** | Native binary on OpenBSD | +| **Binary** | `/usr/local/bin/arcline-web` | +| **Static files** | `/var/www/arclineit/static/` | +| **Service manager** | OpenBSD `rcctl` | +| **Deploy command** | `make deploy` | + +```bash +# Quick deploy +make cross +scp arcline-web-openbsd-amd64 srv01:/usr/local/bin/arcline-web +rsync -av --delete static/ srv01:/var/www/arclineit/static/ +ssh srv01 "rcctl restart arcline-web" +``` + +### billing — `client.arcline.it` + +| Detail | Value | +|--------|-------| +| **Method** | Docker container | +| **Container name** | `arcline-billing` | +| **Internal port** | 8082 | +| **Data volume** | `/opt/arcline-billing/.env:/app/.env:ro` | +| **Database** | SQLite in container (ephemeral — backup on host) | + +```bash +docker run -d \ + --name arcline-billing \ + --restart unless-stopped \ + -p 127.0.0.1:8082:8082 \ + -v /opt/arcline-billing/.env:/app/.env:ro \ + /arcline-billing:latest +``` + +### portal — `portal.arclineit.com` + +| Detail | Value | +|--------|-------| +| **Method** | Docker container | +| **Container name** | `arcline-portal` | +| **Internal port** | 8082 | +| **Data volume** | `/opt/arcline-portal/.env:/app/.env:ro` | +| **Database** | SQLite in container (ephemeral — backup on host) | + +```bash +docker run -d \ + --name arcline-portal \ + --restart unless-stopped \ + -p 127.0.0.1:8082:8082 \ + -v /opt/arcline-portal/.env:/app/.env:ro \ + /arcline-portal:latest +``` + +### git — `git.arcline.it` + +| Detail | Value | +|--------|-------| +| **Method** | Docker container | +| **Container name** | `arcline-gitea` | +| **Internal ports** | 3000 (web), 22 (SSH) | +| **Data volume** | `/opt/arcline-gitea/data:/data` | +| **Database** | SQLite (or PostgreSQL for multi-instance) | + +```bash +# Deploy with docker-compose +docker compose -f git/deploy/docker-compose.yml up -d + +# Or manual +docker run -d \ + --name arcline-gitea \ + --restart unless-stopped \ + -p 127.0.0.1:3000:3000 \ + -p 22:22 \ + -v /opt/arcline-gitea/data:/data \ + -e DOMAIN=git.arcline.it \ + -e ROOT_URL=https://git.arcline.it \ + -e SSH_DOMAIN=git.arcline.it \ + gitea/gitea:latest + +# Nginx config (redirect + proxy) +sudo cp git/deploy/nginx-git-redirect.conf /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx + +# Verify +curl -I https://git.arcline.it +ssh -T _gitea@git.arcline.it +``` + +### uptime — Internal Monitor + +| Detail | Value | +|--------|-------| +| **Method** | Static binary or Docker | +| **Binary** | `/usr/local/bin/arcline-uptime` | +| **Config** | `/etc/arcline/uptime.yaml` | +| **Database** | `/var/lib/arcline/uptime.db` | + +```bash +# As a service +arcline-uptime start --config /etc/arcline/uptime.yaml + +# Or via systemd +[Service] +ExecStart=/usr/local/bin/arcline-uptime start --config /etc/arcline/uptime.yaml +``` + +### status — `status.arclineit.com` + +| Detail | Value | +|--------|-------| +| **Method** | Static HTML generation | +| **Binary** | `/usr/local/bin/arcline-status` | +| **Config** | `/etc/arcline/status.yaml` + `/etc/arcline/status.d/` | +| **Output** | `/var/www/status/` (served by Nginx) | + +```bash +# Generate status page +arcline-status build --config /etc/arcline/status.yaml --out /var/www/status/ + +# Watch mode (regenerate on config change) +arcline-status build --config /etc/arcline/status.yaml --out /var/www/status/ --watch +``` + +### docs — `docs.arclineit.com` + +| Detail | Value | +|--------|-------| +| **Method** | Docker container (dynamic) or Nginx (static) | +| **Container name** | `arcline-docs` | +| **Internal port** | 8080 | + +```bash +# Dynamic variant (Go server with Markdown rendering) +docker run -d \ + --name arcline-docs \ + --restart unless-stopped \ + -p 127.0.0.1:8080:8080 \ + -v /opt/arcline-docs/.env:/app/.env:ro \ + /arcline-docs:latest + +# Static variant (pre-built HTML via Dockerfile.static) +docker run -d \ + --name arcline-docs \ + --restart unless-stopped \ + -p 127.0.0.1:80:80 \ + /arcline-docs:latest-static +``` + +--- + +## Database Management + +### Backup + +```bash +# SQLite databases — simple file copy +cp /opt/arcline-/data/.db /backup/-$(date +%Y%m%d).db + +# Compress +gzip /backup/-*.db +``` + +### Restore + +```bash +# Stop the service first +docker stop arcline- +# or +systemctl stop arcline- + +# Restore database +cp /backup/-.db.gz /opt/arcline-/data/ +gunzip /opt/arcline-/data/-.db.gz +mv /opt/arcline-/data/-.db /opt/arcline-/data/.db +chown arcline:arcline /opt/arcline-/data/.db + +# Restart +docker start arcline- +# or +systemctl start arcline- +``` + +### Maintenance + +```bash +# Vacuum SQLite database (reclaim space) +sqlite3 /opt/arcline-/data/.db "VACUUM;" + +# Integrity check +sqlite3 /opt/arcline-/data/.db "PRAGMA integrity_check;" +``` + +--- + +## Backup & Restore + +### What to Back Up + +| Asset | Location | Frequency | +|-------|----------|-----------| +| SQLite databases | `/opt/arcline-*/data/*.db` | Daily | +| Environment files | `/opt/arcline-*/.env` | On change | +| SSL certificates | `/etc/ssl/` | On renewal | +| Nginx configs | `/etc/nginx/` | On change | +| Systemd units | `/etc/systemd/system/arcline-*.service` | On change | +| Status config | `/etc/arcline/` | On change | +| Uptime config | `/etc/arcline/uptime.yaml` | On change | + +### Backup Script + +```bash +#!/bin/sh +# /usr/local/bin/arcline-backup + +BACKUP_DIR="/backup/arcline/$(date +%Y-%m-%d)" +mkdir -p "$BACKUP_DIR" + +# Databases +for db in /opt/arcline-*/data/*.db; do + cp "$db" "$BACKUP_DIR/" +done + +# Environment files (mask secrets) +for env in /opt/arcline-*/.env; do + cp "$env" "$BACKUP_DIR/$(basename $(dirname $env)).env" +done + +# Configs +cp -r /etc/arcline "$BACKUP_DIR/" + +# Compress +tar czf "$BACKUP_DIR.tar.gz" -C "$(dirname $BACKUP_DIR)" "$(basename $BACKUP_DIR)" +rm -rf "$BACKUP_DIR" + +# Sync to backup host +rsync -av "$BACKUP_DIR.tar.gz" backup-host:/backups/arcline/ +``` + +--- + +## Rollback Procedures + +### Docker Rollback + +```bash +# 1. Check previous image tags +docker images / + +# 2. Deploy a specific tag instead of latest +docker run -d \ + --name -rollback \ + /: + +# 3. Verify health +curl http://127.0.0.1:/health + +# 4. Swap if healthy +docker stop +docker rm +docker rename -rollback +``` + +### Native Binary Rollback + +```bash +# 1. Keep previous binary versions +cp /usr/local/bin/ /usr/local/bin/.bak + +# 2. Restore previous version +cp /usr/local/bin/.bak /usr/local/bin/ + +# 3. Restart service +systemctl restart +# or +rcctl restart +``` + +--- + +## Health Checks + +### Docker Health Check (in Dockerfile) + +```dockerfile +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 +``` + +### Manual Health Check + +```bash +# Web service +curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:/health + +# Process check +pgrep -x + +# Docker check +docker ps --filter "name=" --filter "status=running" +``` + +--- + +## Troubleshooting + +### Service Won't Start + +```bash +# Check systemd logs +journalctl -u arcline- -f + +# Check Docker logs +docker logs arcline- + +# Check binary directly +/opt/arcline-/ 2>&1 + +# Common issues: +# - Missing .env file → ExecStart fails +# - Port conflict → change port in .env +# - Permission denied → check chown/chmod +``` + +### Database Issues + +```bash +# Check SQLite integrity +sqlite3 /opt/arcline-/data/.db "PRAGMA integrity_check;" +# Expected: "ok" + +# Check disk space +df -h /opt/arcline-/data/ + +# Check file permissions +ls -la /opt/arcline-/data/.db +# Expected: -rw-r----- arcline arcline +``` + +### Connection Refused + +```bash +# Check if service is listening +ss -tlnp | grep + +# Check Nginx is running +systemctl status nginx + +# Check pfSense NAT rules +# Verify port forwarding from WAN:443 → host:443 +``` + +### Quick Recovery + +```bash +# Universal restart sequence +docker stop arcline- 2>/dev/null +docker rm arcline- 2>/dev/null +docker pull /:latest +docker run -d --restart unless-stopped \ + -p 127.0.0.1:: \ + -v /opt/arcline-/.env:/app/.env:ro \ + /:latest +``` + diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..12e26ec --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,545 @@ +# Docker Build Guide + +> **Multi-stage Docker builds for every Arcline web service** — small images, +> no CVEs, non-root execution. + +--- + +## Table of Contents + +- [Philosophy](#philosophy) +- [Multi-Stage Build Pattern](#multi-stage-build-pattern) +- [Existing Dockerfiles](#existing-dockerfiles) +- [Dockerfile Reference by Service](#dockerfile-reference-by-service) +- [Docker Compose](#docker-compose) +- [Image Sizes](#image-sizes) +- [Security Practices](#security-practices) +- [Development Workflow](#development-workflow) +- [Registry & Tagging](#registry--tagging) +- [Dockerfile Templates](#dockerfile-templates) +- [Troubleshooting](#troubleshooting) + +--- + +## Philosophy + +1. **Minimal base images** — Alpine Linux (~5MB) as the runtime base. No + Ubuntu, no Debian, no unnecessary packages. +2. **Multi-stage builds** — The Go toolchain is only in the build stage. The + runtime stage contains only the compiled binary + ca-certificates. +3. **Non-root execution** — Every container runs as the `arcline` unprivileged + user. No root in production. +4. **Immutable tags** — Each image is tagged by commit SHA. `latest` is a + convenience pointer, never relied upon for production consistency. +5. **SQLite-friendly** — Databases are stored on mounted volumes (ephemeral + by design), but the binary is fully self-contained. + +--- + +## Multi-Stage Build Pattern + +Every Arcline Dockerfile follows this exact structure: + +``` +┌────────────────────────────────────────────┐ +│ Stage 1: builder │ +│ Base: golang:1.xx-alpine │ +│ │ +│ 1. Set WORKDIR /build │ +│ 2. COPY go.mod go.sum → download deps │ +│ 3. COPY source code │ +│ 4. CGO_ENABLED=0 go build │ +└────────────────────┬───────────────────────┘ + │ + │ COPY --from=builder + ▼ +┌────────────────────────────────────────────┐ +│ Stage 2: runtime │ +│ Base: alpine:3.xx │ +│ │ +│ 1. apk add ca-certificates │ +│ 2. Create arcline user & group │ +│ 3. COPY binary + static assets │ +│ 4. chown to arcline │ +│ 5. USER arcline │ +│ 6. ENV PORT=8080 │ +│ 7. EXPOSE 8080 │ +│ 8. CMD ["./"] │ +└────────────────────────────────────────────┘ +``` + +### Rationale + +| Design Decision | Why | +|----------------|-----| +| `golang:alpine` builder | Smallest Go build image (~300MB vs 1GB+ for debian-based) | +| `alpine:3.xx` runtime | ~5MB base, minimal CVEs, musl libc compatibility | +| `ca-certificates` | Required for TLS connections (Stripe, Let's Encrypt, etc.) | +| `CGO_ENABLED=0` | Fully static binary — no libc dependencies at runtime | +| `-trimpath` | Removes build machine paths from binary | +| `-ldflags="-s -w"` | Strips debug info — smaller binary | +| Non-root user | Security best practice — limits blast radius | + +--- + +## Existing Dockerfiles + +### Summary + +| Service | Path | Go Version | Alpine Version | Port | Binary | Static Assets | +|---------|------|------------|----------------|------|--------|---------------| +| portal | `portal/Dockerfile` | 1.25 | 3.21 | 8080 | `arcline-portal` | `static/` | +| billing | `billing/Dockerfile` | 1.22 | 3.21 | 8082 | `billing` | `static/` | +| website | `website/Dockerfile` | 1.22 | 3.19 | 8081 | `arcline-web` | `static/`, `templates/` | +| docs (dynamic) | `docs/Dockerfile` | 1.22 | 3.20 | 8080 | `docs-server` | `content/`, `static/`, `templates/` | +| docs (static) | `docs/Dockerfile.static` | 1.22 | nginx:alpine | 80 | — (pre-built HTML) | `dist/` | + +### Portal — `portal/Dockerfile` + +```dockerfile +# ── Stage 1: Build ───────────────────────────────────────────────────────── +FROM golang:1.25-alpine AS builder + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o arcline-portal . + +# ── Stage 2: Runtime ──────────────────────────────────────────────────────── +FROM alpine:3.21 AS runtime + +RUN apk add --no-cache ca-certificates && \ + addgroup -S arcline && \ + adduser -S -G arcline arcline + +WORKDIR /app + +COPY --from=builder /build/arcline-portal ./ +COPY --from=builder /build/static ./static + +RUN chown -R arcline:arcline /app + +USER arcline + +ENV PORT=8080 + +EXPOSE 8080 + +CMD ["./arcline-portal"] +``` + +**Key features:** +- Go 1.25 for latest stdlib improvements +- Alpine 3.21 runtime +- Port 8080 (mapped to 8082 in production via nginx or docker `-p` flag) +- Embedded `static/` directory + +### Billing — `billing/Dockerfile` + +```dockerfile +# ── Stage 1: Build ───────────────────────────────────────────────────────── +FROM golang:1.22-alpine AS builder + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o billing . + +# ── Stage 2: Runtime ──────────────────────────────────────────────────────── +FROM alpine:3.21 AS runtime + +RUN apk add --no-cache ca-certificates && \ + addgroup -S arcline && \ + adduser -S -G arcline arcline + +WORKDIR /app + +COPY --from=builder /build/billing ./ +COPY --from=builder /build/static ./static + +RUN chown -R arcline:arcline /app + +USER arcline + +ENV PORT=8082 + +EXPOSE 8082 + +CMD ["./billing"] +``` + +### Website — `website/Dockerfile` + +```dockerfile +# ── Stage 1: Build ───────────────────────────────────────────────────────── +FROM golang:1.22-alpine AS builder + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o arcline-web . + +# ── Stage 2: Runtime ──────────────────────────────────────────────────────── +FROM alpine:3.19 AS runtime + +RUN apk add --no-cache ca-certificates && \ + addgroup -S arcline && \ + adduser -S -G arcline arcline + +WORKDIR /app + +COPY --from=builder /build/arcline-web ./ +COPY --from=builder /build/static ./static +COPY --from=builder /build/templates ./templates + +RUN chown -R arcline:arcline /app + +USER arcline + +ENV PORT=8081 + +EXPOSE 8081 + +CMD ["./arcline-web"] +``` + +**Note:** The website has a separate deployment path for OpenBSD (native +binary, not Docker). The Dockerfile exists for Linux-based testing/staging. + +### Docs (Dynamic) — `docs/Dockerfile` + +```dockerfile +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o docs-server ./cmd/serve + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app +COPY --from=builder /build/docs-server . +COPY content ./content +COPY static ./static +COPY templates ./templates + +EXPOSE 8080 + +ENV PORT=8080 \ + CONTENT_DIR=/app/content \ + STATIC_DIR=/app/static \ + TEMPLATES_DIR=/app/templates + +CMD ["./docs-server"] +``` + +### Docs (Static) — `docs/Dockerfile.static` + +```dockerfile +# Stage 1: build static HTML from Markdown +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go run ./cmd/build + +# Stage 2: serve with nginx +FROM nginx:alpine +COPY --from=builder /build/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +--- + +## Docker Compose + +### Docs — `docs/docker-compose.yml` + +```yaml +services: + docs: + build: . + ports: + - "8080:8080" + environment: + PORT: "8080" + ADMIN_EMAIL: "${ADMIN_EMAIL}" + BILLING_URL: "${BILLING_URL:-https://portal.arcline.it}" + BILLING_DB: /data/billing/arcline-billing.db + DOCS_DB: /data/docs/arcline-docs.db + volumes: + - billing-data:/data/billing:ro + - docs-data:/data/docs + restart: unless-stopped + +volumes: + billing-data: + external: true + name: arcline_billing_data + docs-data: +``` + +**Notes:** +- Docs depends on `arcline_billing_data` volume from the billing service +- This is the only service with Docker Compose — others use `docker run` directly + +--- + +## Image Sizes + +### Current Estimates + +| Image | Build Stage | Runtime | Final Size | +|-------|------------|---------|------------| +| portal | golang:1.25-alpine (~350MB) | alpine:3.21 (~5MB) | **~15-20MB** | +| billing | golang:1.22-alpine (~350MB) | alpine:3.21 (~5MB) | **~15-20MB** | +| website | golang:1.22-alpine (~350MB) | alpine:3.19 (~5MB) | **~20-25MB** | +| docs (dynamic) | golang:1.22-alpine (~350MB) | alpine:3.20 (~5MB) | **~20-30MB** | +| docs (static) | golang:1.22-alpine (~350MB) | nginx:alpine (~25MB) | **~30-40MB** | + +**Optimization tips:** +- Run `go mod tidy` before building to remove unused dependencies +- Use `-ldflags="-s -w"` to strip debug symbols (saves ~30% binary size) +- Use `.dockerignore` to exclude unnecessary files from build context + +--- + +## Security Practices + +### Container Security + +```dockerfile +# 1. Non-root user +RUN addgroup -S arcline && adduser -S -G arcline arcline +USER arcline + +# 2. Minimal packages +RUN apk add --no-cache ca-certificates +# NO: bash, curl, wget, openssl, etc. in production + +# 3. Read-only root filesystem +# (set at runtime: --read-only) +docker run --read-only --tmpfs /tmp ... + +# 4. Drop all capabilities +docker run --cap-drop ALL ... + +# 5. No privilege escalation +# (set at runtime) +docker run --security-opt no-new-privileges ... +``` + +### Production Docker Run + +```bash +docker run -d \ + --name \ + --restart unless-stopped \ + --read-only \ + --tmpfs /tmp:noexec,nosuid,size=64M \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + -p 127.0.0.1:: \ + -v /opt//.env:/app/.env:ro \ + -v /opt//data:/app/data \ + :latest +``` + +### .dockerignore + +Every service should have a `.dockerignore` that excludes: + +``` +.git/ +.gitignore +*.md +*.db # Don't bundle local databases +.env # Don't bundle secrets + # Don't bundle pre-built binaries +-linux-* +``` + +--- + +## Development Workflow + +### Local Build & Test + +```bash +# Build Docker image +docker build -t :dev . + +# Run with local .env +docker run -d \ + --name -dev \ + -p 8080:8080 \ + -v $(pwd)/.env:/app/.env:ro \ + :dev + +# Check logs +docker logs -dev -f + +# Stop and clean up +docker stop -dev +docker rm -dev +``` + +### Hot Reload (Development Only) + +```bash +# Build and run in one command +docker build -t :dev . && \ +docker rm -f -dev 2>/dev/null; \ +docker run -d --name -dev -p 8080:8080 :dev && \ +docker logs -f -dev +``` + +--- + +## Registry & Tagging + +### GitLab Container Registry + +All Arcline images are stored in the self-hosted GitLab Container Registry. + +```bash +# Login +docker login registry.arcline.it + +# Tag for registry +docker tag :latest registry.arcline.it//: + +# Push +docker push registry.arcline.it//:latest +``` + +### Tagging Convention + +| Tag | Source | Use | +|-----|--------|-----| +| `$CI_COMMIT_SHORT_SHA` | CI | Unique deploy artifact | +| `latest` | CI | Latest successful main branch build | +| `vX.Y.Z` | Manual | Versioned release | +| `dev` | Local | Development builds | + +--- + +## Dockerfile Templates + +### Web Service Template + +For services that serve HTTP (portal, billing, website, docs): + +```dockerfile +# ── Stage 1: Build ───────────────────────────────────────────────────────── +FROM golang:1.xx-alpine AS builder + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o . + +# ── Stage 2: Runtime ──────────────────────────────────────────────────────── +FROM alpine:3.xx AS runtime + +RUN apk add --no-cache ca-certificates && \ + addgroup -S arcline && \ + adduser -S -G arcline arcline + +WORKDIR /app + +COPY --from=builder /build/ ./ +# If service has static assets: +# COPY --from=builder /build/static ./static +# If service has templates: +# COPY --from=builder /build/templates ./templates + +RUN chown -R arcline:arcline /app + +USER arcline + +ENV PORT=8080 + +EXPOSE 8080 + +CMD ["./"] +``` + +### CLI Tool Template + +For CLI tools that don't need a web server (uptime, status, check, etc.): + +```dockerfile +# Usually CLI tools don't need Docker — they're installed as native binaries. +# If Docker is needed for isolation: + +FROM alpine:3.xx + +RUN apk add --no-cache ca-certificates && \ + addgroup -S arcline && \ + adduser -S -G arcline arcline + +COPY /usr/local/bin/ + +USER arcline + +ENTRYPOINT ["/usr/local/bin/"] +``` + +--- + +## Troubleshooting + +### Build Issues + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `go: downloading` is slow | No layer caching | Move `COPY go.mod go.sum` before `COPY .` | +| `CGO_ENABLED=0` build fails | CGO dependency | Use `modernc.org/sqlite` instead of `mattn/go-sqlite3` | +| `exec ./binary: no such file` | Binary not in PATH | Use `./binary` or absolute path | +| `standard_init_linux.go:...` | Wrong arch | Build with `GOARCH=amd64` for amd64 hosts | +| Image too large | Debug symbols in binary | Add `-ldflags="-s -w"` | + +### Runtime Issues + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `x509: certificate signed by unknown authority` | Missing ca-certificates | Add `apk add ca-certificates` | +| `permission denied` | Running as root | Use `USER arcline` and `chown` | +| `bind: address already in use` | Port conflict | Change `PORT` env or host mapping | +| SQLite `disk I/O error` | Read-only filesystem | Mount writable volume at data path | +| Container exits immediately | Binary crashes | Run `docker logs ` to see error | + +### Debugging a Container + +```bash +# Enter a running container +docker exec -it /bin/sh + +# Copy files out of a container +docker cp :/app/data/app.db ./app.db + +# Run a one-shot command in the container +docker run --rm -it /bin/sh +``` + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..892cf3b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o docs-server ./cmd/serve + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app +COPY --from=builder /build/docs-server . +COPY content ./content +COPY static ./static +COPY templates ./templates + +EXPOSE 8080 + +ENV PORT=8080 \ + CONTENT_DIR=/app/content \ + STATIC_DIR=/app/static \ + TEMPLATES_DIR=/app/templates \ + BILLING_DB=/data/billing/arcline-billing.db \ + DOCS_DB=/data/docs/arcline-docs.db \ + BILLING_URL=https://portal.arcline.it + +CMD ["./docs-server"] diff --git a/Dockerfile.static b/Dockerfile.static new file mode 100644 index 0000000..c44501b --- /dev/null +++ b/Dockerfile.static @@ -0,0 +1,15 @@ +# Stage 1: build static HTML from Markdown +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go run ./cmd/build + +# Stage 2: serve with nginx +FROM nginx:alpine +COPY --from=builder /build/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8c25ac9 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +BINARY := docs-server +GOFLAGS := -trimpath -ldflags="-s -w" + +.PHONY: build run linux-amd64 linux-arm64 all test clean + +build: + CGO_ENABLED=0 go build $(GOFLAGS) -o $(BINARY) ./cmd/serve + +run: + go run ./cmd/serve + +linux-amd64: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(GOFLAGS) -o $(BINARY)-linux-amd64 ./cmd/serve + +linux-arm64: + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(GOFLAGS) -o $(BINARY)-linux-arm64 ./cmd/serve + +all: linux-amd64 linux-arm64 + +test: + go test ./... + +clean: + rm -f $(BINARY) $(BINARY)-linux-* + +# Static site build +.PHONY: build-static +build-static: + go run ./cmd/build + +build-static-watch: + go run ./cmd/build --watch + + diff --git a/README.md b/README.md index ce22a93..0459919 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,58 @@ # arcline-docs -Knowledge base and migration guides for arclineit.com. Hosted at `docs.arclineit.com`. - -Covers migration from major shared hosts, getting-started guides for new customers, VPS setup walkthroughs, and self-hosting/privacy content that supports the Arcline pitch. +Knowledge base, migration guides, and self-hosting tutorials for Arcline. Hosted at `docs.arclineit.com`. ## Status -Planned. Not yet started. +**Complete.** All content sections are written (33 guides total across 6 sections). The Go static site builder generates a fully functional documentation site with search, sitemap, RSS feed, and 404 pages. -## Format +## Content -Static HTML generated from Markdown (Go builder using goldmark). Uses the Arcline design system (same CSS as the main website). No JavaScript framework, no CMS. +| Area | Status | Guides | +|------|--------|--------| +| Migration guides (GoDaddy, Bluehost, SiteGround, Namecheap, HostGator, WP Engine, domain transfer) | ✅ | 7 | +| Getting started (SSH, SFTP, MySQL backup, email, DNS, SSL) | ✅ | 6 | +| WordPress (install shared/VPS, WooCommerce, caching, security) | ✅ | 5 | +| VPS setup (initial setup, Nginx+PHP+MySQL, static/Node.js/Go deployment, backups, fail2ban) | ✅ | 7 | +| Privacy & self-hosting (no-CDN, arcline-check, performance, email auth) | ✅ | 4 | +| Reference (nameservers, PHP versions, plan limits, AUP, support) | ✅ | 5 | +| Legal (DPA, MSA, SLA) | ✅ | 3 | -## Content areas +## Site Builder Features -| Area | Priority | -|---|---| -| Migration guides (GoDaddy, Bluehost, SiteGround, Namecheap, HostGator, WP Engine) | High | -| Getting started (SSH, SFTP, MySQL backup, email, DNS, SSL) | High | -| WordPress (install, WooCommerce, caching without CDN, security) | Medium | -| VPS setup (Nginx, PHP-FPM, Node.js, Go services, backups, fail2ban) | Medium | -| Privacy & self-hosting (no-CDN tips, arcline-check walkthrough, email auth) | Medium | -| Reference (nameservers, PHP versions, plan limits, AUP summary) | Low | +- **Markdown → HTML** — goldmark renderer with tables, strikethroughs, task lists +- **Arcline design system** — same CSS as the main website +- **Client-side search** — JSON index with keyboard navigation (⌘K) +- **Sitemap** — `sitemap.xml` for SEO +- **RSS feed** — `rss.xml` for content syndication +- **404 page** — custom error page with search +- **Navigation** — sidebar with active page highlighting, breadcrumbs, prev/next pagers +- **Watch mode** — `--watch` flag rebuilds on content/template changes -## Planned features +## Development -- Client-side search (pagefind or simple JSON index) -- Sitemap and RSS feed generation -- Watch mode for local development +```bash +# Build the dynamic server +make build -See [todo.md](todo.md) for the full content plan and builder task list. +# Run the dev server +make run + +# Build the static site (outputs to dist/) +make build-static + +# Watch mode — rebuilds on content/template changes +make build-static-watch +``` + +## Tech Stack + +- **Language**: Go +- **Markdown**: goldmark (tables, strikethrough, task lists, auto heading IDs) +- **Templates**: Go html/template +- **Storage**: SQLite (for client/admin pages) +- **Watch mode**: fsnotify +- **Static output**: `dist/` directory (self-hostable via any web server) ## License diff --git a/build b/build new file mode 100755 index 0000000..88c89aa Binary files /dev/null and b/build differ diff --git a/cmd/build/main.go b/cmd/build/main.go new file mode 100644 index 0000000..306e77c --- /dev/null +++ b/cmd/build/main.go @@ -0,0 +1,690 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + goldmarkhtml "github.com/yuin/goldmark/renderer/html" +) + +// ── types ───────────────────────────────────────────────────────────────────── + +type Page struct { + Title string + Description string + Section string + Order int + Slug string + URL string + Content template.HTML + Excerpt string +} + +type Section struct { + Slug string + Title string + Pages []*Page +} + +type SidebarPage struct { + Title string + URL string + Active bool +} + +type SidebarSection struct { + Title string + URL string + Pages []SidebarPage +} + +type Breadcrumb struct { + Label string + URL string +} + +type PageLink struct { + Title string + URL string +} + +type PageData struct { + Title string + Description string + Content template.HTML + Root string + Canonical string + Nav []SidebarSection + Breadcrumbs []Breadcrumb + Prev *PageLink + Next *PageLink + Year int + Customer any + IsAdmin bool +} + +type SearchDoc struct { + Title string `json:"title"` + URL string `json:"url"` + Section string `json:"section"` + Excerpt string `json:"excerpt"` +} + +type RSSItem struct { + Title string + URL string + Description string + Section string +} + +// ── config ──────────────────────────────────────────────────────────────────── + +const ( + siteBaseURL = "https://docs.arcline.it" + distDir = "dist" + contentDir = "content" + staticDir = "static" +) + +var sectionMeta = []struct { + Slug string + Title string +}{ + {"getting-started", "Getting Started"}, + {"migrate", "Migration Guides"}, + {"wordpress", "WordPress"}, + {"vps", "VPS Guides"}, + {"privacy", "Privacy & Self-Hosting"}, + {"reference", "Reference"}, +} + +// ── main ────────────────────────────────────────────────────────────────────── + +func main() { + watch := false + for _, arg := range os.Args[1:] { + if arg == "--watch" { + watch = true + } + } + + if watch { + runWatch() + } else { + build() + } +} + +func build() { + start := time.Now() + + if err := os.RemoveAll(distDir); err != nil { + die("rm dist: %v", err) + } + + pages, err := collectPages() + if err != nil { + die("collect pages: %v", err) + } + + sections := buildSections(pages) + + tmpl := template.New("").Funcs(template.FuncMap{ + "hasPrefix": strings.HasPrefix, + }) + tmpl, err = template.ParseFiles("templates/page.html", "templates/layout.html") + if err != nil { + die("parse template: %v", err) + } + + for _, sec := range sections { + for i, page := range sec.Pages { + var prev, next *PageLink + if i > 0 { + prev = &PageLink{Title: sec.Pages[i-1].Title, URL: sec.Pages[i-1].URL} + } + if i < len(sec.Pages)-1 { + next = &PageLink{Title: sec.Pages[i+1].Title, URL: sec.Pages[i+1].URL} + } + if err := renderPage(tmpl, page, sections, sec, prev, next); err != nil { + die("render %s: %v", page.URL, err) + } + } + if err := renderSectionIndex(tmpl, sec, sections); err != nil { + die("render section index %s: %v", sec.Slug, err) + } + } + + if err := renderHome(tmpl, sections); err != nil { + die("render home: %v", err) + } + if err := render404(tmpl); err != nil { + die("render 404: %v", err) + } + if err := copyDir(staticDir, distDir); err != nil { + die("copy static: %v", err) + } + + fontSrc := filepath.Join("..", "website", "static", "public", "fonts") + if info, err := os.Stat(fontSrc); err == nil && info.IsDir() { + fontDst := filepath.Join(distDir, "public", "fonts") + if err := copyDir(fontSrc, fontDst); err != nil { + fmt.Fprintf(os.Stderr, "warn: copy fonts: %v\n", err) + } + } + + if err := generateSearch(pages, sections); err != nil { + die("generate search: %v", err) + } + if err := generateSitemap(pages, sections); err != nil { + die("generate sitemap: %v", err) + } + if err := generateRSS(pages, sections); err != nil { + die("generate rss: %v", err) + } + + total := 0 + for _, sec := range sections { + total += len(sec.Pages) + } + fmt.Printf("Built %d pages in %s → %s/\n", total, time.Since(start).Round(time.Millisecond), distDir) +} + +func runWatch() { + fmt.Println("Watching for changes (Ctrl+C to stop)...") + build() + + watcher, err := fsnotify.NewWatcher() + if err != nil { + die("fsnotify: %v", err) + } + defer watcher.Close() + + for _, dir := range []string{contentDir, "templates", staticDir} { + if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return watcher.Add(path) + } + return nil + }); err != nil { + die("watch add %s: %v", dir, err) + } + } + + debounce := time.NewTimer(0) + if !debounce.Stop() { + <-debounce.C + } + + for { + select { + case event := <-watcher.Events: + if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove) != 0 { + debounce.Reset(300 * time.Millisecond) + } + case err := <-watcher.Errors: + slog.Warn("watch error", "err", err) + case <-debounce.C: + fmt.Println("\nChange detected, rebuilding...") + build() + fmt.Println("Watching for changes (Ctrl+C to stop)...") + } + } +} + +// ── collect ─────────────────────────────────────────────────────────────────── + +var md = goldmark.New( + goldmark.WithExtensions( + extension.Table, + extension.Strikethrough, + extension.TaskList, + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + goldmarkhtml.WithUnsafe(), + ), +) + +func collectPages() ([]*Page, error) { + var pages []*Page + + err := filepath.WalkDir(contentDir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") { + return err + } + + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + fm, body := parseFrontmatter(raw) + + var buf bytes.Buffer + if err := md.Convert(body, &buf); err != nil { + return fmt.Errorf("convert %s: %w", path, err) + } + + rel, _ := filepath.Rel(contentDir, path) + parts := strings.Split(filepath.ToSlash(rel), "/") + + var section, slug string + if len(parts) == 1 { + return nil + } + section = parts[0] + slug = strings.TrimSuffix(parts[len(parts)-1], ".md") + + url := "/" + section + "/" + slug + "/" + order, _ := strconv.Atoi(fm["order"]) + htmlContent := buf.String() + + pages = append(pages, &Page{ + Title: fm["title"], + Description: fm["description"], + Section: section, + Order: order, + Slug: slug, + URL: url, + Content: template.HTML(htmlContent), + Excerpt: htmlExcerpt(htmlContent, 220), + }) + return nil + }) + + return pages, err +} + +func parseFrontmatter(raw []byte) (map[string]string, []byte) { + fm := make(map[string]string) + s := string(raw) + if !strings.HasPrefix(s, "---") { + return fm, raw + } + rest := s[3:] + if rest != "" && rest[0] == '\n' { + rest = rest[1:] + } + end := strings.Index(rest, "---") + if end < 0 { + return fm, raw + } + block := rest[:end] + body := rest[end+3:] + if len(body) > 0 && body[0] == '\n' { + body = body[1:] + } + + for _, line := range strings.Split(block, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + idx := strings.Index(line, ":") + if idx < 0 { + continue + } + k := strings.TrimSpace(line[:idx]) + v := strings.TrimSpace(line[idx+1:]) + v = strings.Trim(v, `"'`) + fm[k] = v + } + return fm, []byte(body) +} + +var tagRe = regexp.MustCompile(`<[^>]+>`) + +func htmlExcerpt(h string, maxLen int) string { + text := tagRe.ReplaceAllString(h, " ") + text = strings.Join(strings.Fields(text), " ") + if len([]rune(text)) > maxLen { + r := []rune(text)[:maxLen] + text = string(r) + "…" + } + return text +} + +// ── sections ────────────────────────────────────────────────────────────────── + +func buildSections(pages []*Page) []*Section { + bySlug := make(map[string]*Section) + for _, sm := range sectionMeta { + bySlug[sm.Slug] = &Section{Slug: sm.Slug, Title: sm.Title} + } + + for _, p := range pages { + sec := bySlug[p.Section] + if sec == nil { + sec = &Section{ + Slug: p.Section, + Title: strings.ReplaceAll(strings.ToTitle(p.Section[:1])+p.Section[1:], "-", " "), + } + bySlug[p.Section] = sec + } + sec.Pages = append(sec.Pages, p) + } + + for _, sec := range bySlug { + sort.Slice(sec.Pages, func(i, j int) bool { + if sec.Pages[i].Order != sec.Pages[j].Order { + return sec.Pages[i].Order < sec.Pages[j].Order + } + return sec.Pages[i].Title < sec.Pages[j].Title + }) + } + + var out []*Section + for _, sm := range sectionMeta { + if sec := bySlug[sm.Slug]; sec != nil && len(sec.Pages) > 0 { + out = append(out, sec) + } + } + return out +} + +func buildNav(sections []*Section, currentURL string) []SidebarSection { + nav := make([]SidebarSection, 0, len(sections)) + for _, sec := range sections { + pages := make([]SidebarPage, 0, len(sec.Pages)) + for _, p := range sec.Pages { + pages = append(pages, SidebarPage{ + Title: p.Title, + URL: p.URL, + Active: p.URL == currentURL, + }) + } + nav = append(nav, SidebarSection{ + Title: sec.Title, + URL: "/" + sec.Slug + "/", + Pages: pages, + }) + } + return nav +} + +// ── render ──────────────────────────────────────────────────────────────────── + +func renderPage(tmpl *template.Template, page *Page, sections []*Section, sec *Section, prev, next *PageLink) error { + outPath := filepath.Join(distDir, page.Section, page.Slug, "index.html") + f := mustCreate(outPath) + defer f.Close() + + return tmpl.Execute(f, PageData{ + Title: page.Title + " — Arcline Docs", + Description: page.Description, + Content: page.Content, + Root: "../../", + Canonical: siteBaseURL + page.URL, + Nav: buildNav(sections, page.URL), + Breadcrumbs: []Breadcrumb{ + {Label: sec.Title, URL: "/" + sec.Slug + "/"}, + {Label: page.Title}, + }, + Prev: prev, + Next: next, + Year: time.Now().Year(), + }) +} + +func renderSectionIndex(tmpl *template.Template, sec *Section, sections []*Section) error { + outPath := filepath.Join(distDir, sec.Slug, "index.html") + f := mustCreate(outPath) + defer f.Close() + + var buf bytes.Buffer + buf.WriteString(``) + + return tmpl.Execute(f, PageData{ + Title: sec.Title + " — Arcline Docs", + Description: "Guides in the " + sec.Title + " section.", + Content: template.HTML(buf.String()), + Root: "../", + Canonical: siteBaseURL + "/" + sec.Slug + "/", + Nav: buildNav(sections, "/"+sec.Slug+"/"), + Breadcrumbs: []Breadcrumb{{Label: sec.Title}}, + Year: time.Now().Year(), + }) +} + +func renderHome(tmpl *template.Template, sections []*Section) error { + outPath := filepath.Join(distDir, "index.html") + f := mustCreate(outPath) + defer f.Close() + + var buf bytes.Buffer + buf.WriteString(`

Find guides on getting connected, migrating from other hosts, and managing your Arcline hosting account.

`) + for _, sec := range sections { + fmt.Fprintf(&buf, `

%s

    `, + sec.Slug, template.HTMLEscapeString(sec.Title)) + for _, p := range sec.Pages { + fmt.Fprintf(&buf, `
  • %s`, p.URL, template.HTMLEscapeString(p.Title)) + if p.Description != "" { + fmt.Fprintf(&buf, ` — %s`, template.HTMLEscapeString(p.Description)) + } + buf.WriteString(`
  • `) + } + buf.WriteString(`
`) + } + + return tmpl.Execute(f, PageData{ + Title: "Arcline Docs — Knowledge Base", + Description: "Guides, tutorials, and reference docs for Arcline hosting customers.", + Content: template.HTML(buf.String()), + Root: "", + Canonical: siteBaseURL + "/", + Nav: buildNav(sections, "/"), + Year: time.Now().Year(), + }) +} + +func render404(tmpl *template.Template) error { + outPath := filepath.Join(distDir, "404.html") + f := mustCreate(outPath) + defer f.Close() + + content := template.HTML(` +
+

Page not found

+

The page you're looking for doesn't exist or has been moved.

+

← Back to docs home

+
`) + + return tmpl.Execute(f, PageData{ + Title: "Page not found — Arcline Docs", + Description: "The page you're looking for doesn't exist or has been moved.", + Content: content, + Root: "", + Canonical: "", + Nav: nil, + Year: time.Now().Year(), + }) +} + +// ── static assets ───────────────────────────────────────────────────────────── + +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, path) + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + return copyFile(path, target) + }) +} + +func copyFile(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +func mustCreate(path string) *os.File { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + die("mkdir %s: %v", filepath.Dir(path), err) + } + f, err := os.Create(path) + if err != nil { + die("create %s: %v", path, err) + } + return f +} + +// ── search ──────────────────────────────────────────────────────────────────── + +func generateSearch(pages []*Page, sections []*Section) error { + secTitle := make(map[string]string, len(sections)) + for _, sec := range sections { + secTitle[sec.Slug] = sec.Title + } + + docs := make([]SearchDoc, 0, len(pages)) + for _, p := range pages { + docs = append(docs, SearchDoc{ + Title: p.Title, + URL: p.URL, + Section: secTitle[p.Section], + Excerpt: p.Excerpt, + }) + } + + data, err := json.Marshal(docs) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(distDir, "search.json"), data, 0644) +} + +// ── sitemap ─────────────────────────────────────────────────────────────────── + +func generateSitemap(pages []*Page, sections []*Section) error { + var b strings.Builder + b.WriteString("\n") + b.WriteString("\n") + + add := func(url, priority string) { + fmt.Fprintf(&b, " \n %s%s\n monthly\n %s\n \n", + siteBaseURL, url, priority) + } + + add("/", "1.0") + for _, sec := range sections { + add("/"+sec.Slug+"/", "0.8") + for _, p := range sec.Pages { + add(p.URL, "0.7") + } + } + + b.WriteString("\n") + return os.WriteFile(filepath.Join(distDir, "sitemap.xml"), []byte(b.String()), 0644) +} + +// ── RSS ─────────────────────────────────────────────────────────────────────── + +func generateRSS(pages []*Page, sections []*Section) error { + secTitle := make(map[string]string, len(sections)) + for _, sec := range sections { + secTitle[sec.Slug] = sec.Title + } + + var items []RSSItem + for _, sec := range sections { + for _, p := range sec.Pages { + items = append(items, RSSItem{ + Title: p.Title, + URL: siteBaseURL + p.URL, + Description: p.Description, + Section: secTitle[p.Section], + }) + } + } + + now := time.Now().Format(time.RFC1123Z) + + var b strings.Builder + b.WriteString("\n") + b.WriteString("\n") + b.WriteString("\n") + fmt.Fprintf(&b, " Arcline Docs\n") + fmt.Fprintf(&b, " %s/\n", siteBaseURL) + fmt.Fprintf(&b, " Guides, tutorials, and reference docs for Arcline hosting customers.\n") + fmt.Fprintf(&b, " en-us\n") + fmt.Fprintf(&b, " %s\n", now) + fmt.Fprintf(&b, " \n", siteBaseURL) + + for _, item := range items { + fmt.Fprintf(&b, " \n") + fmt.Fprintf(&b, " %s\n", escapeXML(item.Title)) + fmt.Fprintf(&b, " %s\n", escapeXML(item.URL)) + fmt.Fprintf(&b, " %s\n", escapeXML(item.URL)) + fmt.Fprintf(&b, " %s\n", escapeXML(item.Description)) + fmt.Fprintf(&b, " %s\n", escapeXML(item.Section)) + fmt.Fprintf(&b, " \n") + } + + b.WriteString("\n") + b.WriteString("\n") + return os.WriteFile(filepath.Join(distDir, "rss.xml"), []byte(b.String()), 0644) +} + +func escapeXML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/serve/main.go b/cmd/serve/main.go new file mode 100644 index 0000000..79765a5 --- /dev/null +++ b/cmd/serve/main.go @@ -0,0 +1,762 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "arclineit.com/docs/internal/store" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + goldmarkhtml "github.com/yuin/goldmark/renderer/html" +) + +// ── Config ───────────────────────────────────────────────────────────────────── + +type Config struct { + Port string + BillingDB string + DocsDB string + AdminEmail string + BillingURL string + ContentDir string + StaticDir string + TemplatesDir string +} + +func configFromEnv() Config { + get := func(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def + } + return Config{ + Port: get("PORT", "8080"), + BillingDB: get("BILLING_DB", "local/billing.db"), + DocsDB: get("DOCS_DB", "local/docs.db"), + AdminEmail: get("ADMIN_EMAIL", ""), + BillingURL: get("BILLING_URL", "https://portal.arcline.it"), + ContentDir: get("CONTENT_DIR", "content"), + StaticDir: get("STATIC_DIR", "static"), + TemplatesDir: get("TEMPLATES_DIR", "templates"), + } +} + +// ── Markdown ─────────────────────────────────────────────────────────────────── + +var md = goldmark.New( + goldmark.WithExtensions(extension.Table, extension.Strikethrough, extension.TaskList), + goldmark.WithParserOptions(parser.WithAutoHeadingID()), + goldmark.WithRendererOptions(goldmarkhtml.WithUnsafe()), +) + +var frontmatterRe = regexp.MustCompile(`(?s)^---\n(.+?)\n---\n?`) + +type frontmatter struct { + Title string + Description string + Section string + Order int +} + +func parseFrontmatter(src []byte) (frontmatter, []byte) { + m := frontmatterRe.FindSubmatch(src) + if m == nil { + return frontmatter{}, src + } + var fm frontmatter + for _, line := range strings.Split(string(m[1]), "\n") { + k, v, ok := strings.Cut(line, ":") + if !ok { + continue + } + v = strings.TrimSpace(v) + switch strings.TrimSpace(k) { + case "title": + fm.Title = strings.Trim(v, `"`) + case "description": + fm.Description = strings.Trim(v, `"`) + case "section": + fm.Section = v + case "order": + fm.Order, _ = strconv.Atoi(v) + } + } + return fm, src[len(m[0]):] +} + +func renderMarkdown(src []byte) (template.HTML, error) { + var buf bytes.Buffer + if err := md.Convert(src, &buf); err != nil { + return "", err + } + return template.HTML(buf.String()), nil +} + +// ── Content loading ──────────────────────────────────────────────────────────── + +type publicPage struct { + Title string + Description string + Section string + Slug string + URL string + Order int + RawContent []byte +} + +type publicSection struct { + Slug string + Title string + Pages []*publicPage +} + +var sectionMeta = []struct{ Slug, Title string }{ + {"getting-started", "Getting Started"}, + {"migrate", "Migration Guides"}, + {"wordpress", "WordPress"}, + {"vps", "VPS Guides"}, + {"privacy", "Privacy & Self-Hosting"}, + {"reference", "Reference"}, +} + +func loadContent(contentDir string) ([]*publicSection, error) { + bySlug := map[string]*publicSection{} + + err := filepath.WalkDir(contentDir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") { + return err + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + fm, body := parseFrontmatter(raw) + + rel, _ := filepath.Rel(contentDir, path) + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) != 2 { + return nil + } + secSlug := parts[0] + slug := strings.TrimSuffix(parts[1], ".md") + + if _, ok := bySlug[secSlug]; !ok { + title := secSlug + for _, m := range sectionMeta { + if m.Slug == secSlug { + title = m.Title + break + } + } + bySlug[secSlug] = &publicSection{Slug: secSlug, Title: title} + } + + bySlug[secSlug].Pages = append(bySlug[secSlug].Pages, &publicPage{ + Title: fm.Title, + Description: fm.Description, + Section: secSlug, + Slug: slug, + URL: "/" + secSlug + "/" + slug + "/", + Order: fm.Order, + RawContent: body, + }) + return nil + }) + if err != nil { + return nil, err + } + + var sections []*publicSection + for _, m := range sectionMeta { + if sec, ok := bySlug[m.Slug]; ok { + sort.Slice(sec.Pages, func(i, j int) bool { + return sec.Pages[i].Order < sec.Pages[j].Order + }) + sections = append(sections, sec) + } + } + return sections, nil +} + +// ── Template data ────────────────────────────────────────────────────────────── + +type navPage struct{ Title, URL string; Active bool } +type navSection struct{ Title, URL string; Pages []navPage } +type breadcrumb struct{ Label, URL string } +type pageLink struct{ Title, URL string } + +// TD is the single template data struct used by every template. +type TD struct { + // Page meta + Title, Description string + Content template.HTML + Canonical string + Year int + + // Auth + Customer *store.Customer + IsAdmin bool + + // Public doc sidebar/nav (non-nil on public doc pages only) + Nav []navSection + Breadcrumbs []breadcrumb + Prev, Next *pageLink + + // Client docs listing + Pages []*store.Page + + // Single client/admin page + Page *store.Page + + // Admin edit form + PlanOptions []store.PlanOption + Customers []store.Customer + Error string + CSRFToken string +} + +// ── Handler ──────────────────────────────────────────────────────────────────── + +type handler struct { + cfg Config + st *store.Store + tmpl *template.Template + sections []*publicSection +} + +func newHandler(cfg Config, st *store.Store) (*handler, error) { + sections, err := loadContent(cfg.ContentDir) + if err != nil { + return nil, fmt.Errorf("load content: %w", err) + } + + files, err := filepath.Glob(cfg.TemplatesDir + "/*.html") + if err != nil || len(files) == 0 { + return nil, fmt.Errorf("no templates in %s", cfg.TemplatesDir) + } + funcs := template.FuncMap{ + "visLabel": store.VisibilityLabel, + "hasPrefix": strings.HasPrefix, + } + tmpl, err := template.New("").Funcs(funcs).ParseFiles(files...) + if err != nil { + return nil, fmt.Errorf("parse templates: %w", err) + } + + return &handler{cfg: cfg, st: st, tmpl: tmpl, sections: sections}, nil +} + +func (h *handler) render(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil { + slog.Error("template error", "name", name, "err", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// ── Auth ─────────────────────────────────────────────────────────────────────── + +func (h *handler) currentCustomer(r *http.Request) (*store.Customer, *store.Subscription) { + cookie, err := r.Cookie("session") + if err != nil { + return nil, nil + } + c, err := h.st.GetCustomerBySession(cookie.Value) + if err != nil || c == nil { + return nil, nil + } + sub, _ := h.st.GetSubscription(c.ID) + return c, sub +} + +func (h *handler) isAdmin(c *store.Customer) bool { + return c != nil && h.cfg.AdminEmail != "" && c.Email == h.cfg.AdminEmail +} + +func (h *handler) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c, _ := h.currentCustomer(r); c == nil { + http.Redirect(w, r, h.cfg.BillingURL+"/login", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +func (h *handler) requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, _ := h.currentCustomer(r) + if !h.isAdmin(c) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// ── Nav ──────────────────────────────────────────────────────────────────────── + +func (h *handler) buildNav(activeSec, activeSlug string) []navSection { + nav := make([]navSection, 0, len(h.sections)) + for _, sec := range h.sections { + pages := make([]navPage, 0, len(sec.Pages)) + for _, p := range sec.Pages { + pages = append(pages, navPage{ + Title: p.Title, + URL: p.URL, + Active: sec.Slug == activeSec && p.Slug == activeSlug, + }) + } + nav = append(nav, navSection{Title: sec.Title, URL: "/" + sec.Slug + "/", Pages: pages}) + } + return nav +} + +// ── Public handlers ──────────────────────────────────────────────────────────── + +func (h *handler) home(w http.ResponseWriter, r *http.Request) { + c, _ := h.currentCustomer(r) + + var sb strings.Builder + sb.WriteString(`

Arcline Documentation

` + + `

Guides for getting started with Arcline hosting, migrating from other providers, and managing your account.

`) + for _, sec := range h.sections { + sb.WriteString(fmt.Sprintf( + `

%s

    `, + sec.Slug, sec.Title, + )) + for _, p := range sec.Pages { + sb.WriteString(fmt.Sprintf(`
  • %s`, p.URL, p.Title)) + if p.Description != "" { + sb.WriteString(fmt.Sprintf(` — %s`, p.Description)) + } + sb.WriteString(`
  • `) + } + sb.WriteString(`
`) + } + if c != nil { + sb.WriteString(`

` + + `My Docs

` + + `

Private guides and documentation specific to your account.

`) + } + sb.WriteString(`
`) + + h.render(w, "page.html", TD{ + Title: "Arcline Documentation", + Canonical: "https://docs.arcline.it/", + Nav: h.buildNav("", ""), + Year: time.Now().Year(), + Content: template.HTML(sb.String()), + Customer: c, IsAdmin: h.isAdmin(c), + }) +} + +// pathParts splits a URL path into its non-empty segments. +// "/getting-started/ssh/" → ["getting-started", "ssh"] +func pathParts(p string) []string { + var parts []string + for _, s := range strings.Split(strings.Trim(p, "/"), "/") { + if s != "" { + parts = append(parts, s) + } + } + return parts +} + +func (h *handler) sectionIndex(w http.ResponseWriter, r *http.Request) { + parts := pathParts(r.URL.Path) + if len(parts) != 1 { + http.NotFound(w, r) + return + } + slug := parts[0] + var sec *publicSection + for _, s := range h.sections { + if s.Slug == slug { + sec = s + break + } + } + if sec == nil { + http.NotFound(w, r) + return + } + c, _ := h.currentCustomer(r) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf( + `

%s

`, sec.Title, + )) + for _, p := range sec.Pages { + sb.WriteString(fmt.Sprintf( + `%s`+ + `%s`, + p.URL, p.Title, p.Description, + )) + } + sb.WriteString(`
`) + + h.render(w, "page.html", TD{ + Title: sec.Title + " — Arcline Docs", + Canonical: "https://docs.arcline.it/" + slug + "/", + Nav: h.buildNav(slug, ""), + Breadcrumbs: []breadcrumb{{Label: sec.Title}}, + Year: time.Now().Year(), + Content: template.HTML(sb.String()), + Customer: c, IsAdmin: h.isAdmin(c), + }) +} + +func (h *handler) publicPage(w http.ResponseWriter, r *http.Request) { + parts := pathParts(r.URL.Path) + if len(parts) != 2 { + http.NotFound(w, r) + return + } + secSlug, pageSlug := parts[0], parts[1] + + var sec *publicSection + for _, s := range h.sections { + if s.Slug == secSlug { + sec = s + break + } + } + if sec == nil { + http.NotFound(w, r) + return + } + + var pg *publicPage + var idx int + for i, p := range sec.Pages { + if p.Slug == pageSlug { + pg, idx = p, i + break + } + } + if pg == nil { + http.NotFound(w, r) + return + } + + content, err := renderMarkdown(pg.RawContent) + if err != nil { + http.Error(w, "render error", http.StatusInternalServerError) + return + } + + var prev, next *pageLink + if idx > 0 { + prev = &pageLink{sec.Pages[idx-1].Title, sec.Pages[idx-1].URL} + } + if idx < len(sec.Pages)-1 { + next = &pageLink{sec.Pages[idx+1].Title, sec.Pages[idx+1].URL} + } + + c, _ := h.currentCustomer(r) + h.render(w, "page.html", TD{ + Title: pg.Title + " — Arcline Docs", Description: pg.Description, + Canonical: "https://docs.arcline.it/" + secSlug + "/" + pageSlug + "/", + Nav: h.buildNav(secSlug, pageSlug), + Breadcrumbs: []breadcrumb{ + {Label: sec.Title, URL: "/" + secSlug + "/"}, + {Label: pg.Title}, + }, + Prev: prev, Next: next, + Year: time.Now().Year(), + Content: template.HTML(`
`) + content + `
`, + Customer: c, IsAdmin: h.isAdmin(c), + }) +} + +// ── Client handlers ──────────────────────────────────────────────────────────── + +func (h *handler) clientIndex(w http.ResponseWriter, r *http.Request) { + c, sub := h.currentCustomer(r) + pages, err := h.st.ListVisiblePages(c, sub) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + h.render(w, "client_index.html", TD{ + Year: time.Now().Year(), Customer: c, IsAdmin: h.isAdmin(c), Pages: pages, + }) +} + +func (h *handler) clientPage(w http.ResponseWriter, r *http.Request) { + c, sub := h.currentCustomer(r) + pg, err := h.st.GetPageBySlug(r.PathValue("slug")) + if err != nil || pg == nil { + http.NotFound(w, r) + return + } + if !store.CanSee(c, sub, pg.Visibility) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + content, err := renderMarkdown([]byte(pg.Content)) + if err != nil { + http.Error(w, "render error", http.StatusInternalServerError) + return + } + h.render(w, "client_page.html", TD{ + Year: time.Now().Year(), Customer: c, IsAdmin: h.isAdmin(c), + Page: pg, + Content: template.HTML(`
`) + content + `
`, + }) +} + +// ── Admin handlers ───────────────────────────────────────────────────────────── + +func (h *handler) adminPages(w http.ResponseWriter, r *http.Request) { + c, _ := h.currentCustomer(r) + pages, err := h.st.ListPages() + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + h.render(w, "admin_pages.html", TD{ + Year: time.Now().Year(), Customer: c, IsAdmin: true, Pages: pages, + CSRFToken: csrfGet(w, r), + }) +} + +func (h *handler) adminNewPage(w http.ResponseWriter, r *http.Request) { + c, _ := h.currentCustomer(r) + customers, _ := h.st.ListCustomers() + h.render(w, "admin_edit.html", TD{ + Year: time.Now().Year(), Customer: c, IsAdmin: true, + PlanOptions: store.PlanOptions(), Customers: customers, + CSRFToken: csrfGet(w, r), + }) +} + +func (h *handler) adminCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil || !csrfCheck(r) { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + pg := pageFromForm(r, 0) + if pg.Title == "" || pg.Slug == "" { + h.adminEditError(w, r, pg, "Title and slug are required.") + return + } + if _, err := h.st.CreatePage(pg); err != nil { + msg := "Could not save page." + if strings.Contains(err.Error(), "UNIQUE") { + msg = "A page with that slug already exists." + } + h.adminEditError(w, r, pg, msg) + return + } + http.Redirect(w, r, "/admin/", http.StatusSeeOther) +} + +func (h *handler) adminEditPage(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + pg, err := h.st.GetPageByID(id) + if err != nil || pg == nil { + http.NotFound(w, r) + return + } + c, _ := h.currentCustomer(r) + customers, _ := h.st.ListCustomers() + h.render(w, "admin_edit.html", TD{ + Year: time.Now().Year(), Customer: c, IsAdmin: true, Page: pg, + PlanOptions: store.PlanOptions(), Customers: customers, + CSRFToken: csrfGet(w, r), + }) +} + +func (h *handler) adminUpdate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil || !csrfCheck(r) { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + pg := pageFromForm(r, id) + if pg.Title == "" || pg.Slug == "" { + h.adminEditError(w, r, pg, "Title and slug are required.") + return + } + if err := h.st.UpdatePage(pg); err != nil { + msg := "Could not update page." + if strings.Contains(err.Error(), "UNIQUE") { + msg = "A page with that slug already exists." + } + h.adminEditError(w, r, pg, msg) + return + } + http.Redirect(w, r, "/admin/", http.StatusSeeOther) +} + +func (h *handler) adminDelete(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil || !csrfCheck(r) { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + _ = h.st.DeletePage(id) + http.Redirect(w, r, "/admin/", http.StatusSeeOther) +} + +func (h *handler) adminEditError(w http.ResponseWriter, r *http.Request, pg *store.Page, msg string) { + c, _ := h.currentCustomer(r) + customers, _ := h.st.ListCustomers() + h.render(w, "admin_edit.html", TD{ + Year: time.Now().Year(), Customer: c, IsAdmin: true, Page: pg, + PlanOptions: store.PlanOptions(), Customers: customers, + Error: msg, CSRFToken: csrfGet(w, r), + }) +} + +// ── Logout ───────────────────────────────────────────────────────────────────── + +func (h *handler) logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// ── CSRF ─────────────────────────────────────────────────────────────────────── + +func csrfGet(w http.ResponseWriter, r *http.Request) string { + if c, err := r.Cookie("csrf"); err == nil && len(c.Value) == 64 { + return c.Value + } + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "" + } + token := hex.EncodeToString(b) + http.SetCookie(w, &http.Cookie{ + Name: "csrf", Value: token, Path: "/", + SameSite: http.SameSiteStrictMode, + }) + return token +} + +func csrfCheck(r *http.Request) bool { + c, err := r.Cookie("csrf") + if err != nil { + return false + } + return c.Value != "" && r.FormValue("csrf_token") == c.Value +} + +// ── Form helpers ─────────────────────────────────────────────────────────────── + +func pageFromForm(r *http.Request, id int64) *store.Page { + order, _ := strconv.Atoi(r.FormValue("display_order")) + var visibility string + switch r.FormValue("vis_type") { + case "plan": + visibility = "plan:" + r.FormValue("vis_plan") + case "customer": + visibility = "customer:" + r.FormValue("vis_customer") + default: + visibility = "public" + } + return &store.Page{ + ID: id, + Title: strings.TrimSpace(r.FormValue("title")), + Slug: strings.TrimSpace(r.FormValue("slug")), + Description: strings.TrimSpace(r.FormValue("description")), + Section: strings.TrimSpace(r.FormValue("section")), + Content: r.FormValue("content"), + Visibility: visibility, + DisplayOrder: order, + } +} + +// ── Main ─────────────────────────────────────────────────────────────────────── + +func main() { + cfg := configFromEnv() + + st, err := store.New(cfg.BillingDB, cfg.DocsDB) + if err != nil { + slog.Error("store init failed", "err", err) + os.Exit(1) + } + defer st.Close() + + h, err := newHandler(cfg, st) + if err != nil { + slog.Error("handler init failed", "err", err) + os.Exit(1) + } + + mux := http.NewServeMux() + + // Auth + mux.HandleFunc("GET /logout", h.logout) + + // Admin (all fixed-prefix, no wildcard subtree conflicts) + mux.Handle("GET /admin/", h.requireAdmin(http.HandlerFunc(h.adminPages))) + mux.Handle("GET /admin/pages/new", h.requireAdmin(http.HandlerFunc(h.adminNewPage))) + mux.Handle("POST /admin/pages", h.requireAdmin(http.HandlerFunc(h.adminCreate))) + mux.Handle("GET /admin/pages/{id}/edit", h.requireAdmin(http.HandlerFunc(h.adminEditPage))) + mux.Handle("POST /admin/pages/{id}", h.requireAdmin(http.HandlerFunc(h.adminUpdate))) + mux.Handle("POST /admin/pages/{id}/delete", h.requireAdmin(http.HandlerFunc(h.adminDelete))) + + // Client docs (fixed prefix /client/, no conflict with admin) + mux.Handle("GET /client/", h.requireAuth(http.HandlerFunc(h.clientIndex))) + mux.Handle("GET /client/{slug}/", h.requireAuth(http.HandlerFunc(h.clientPage))) + + // Root catch-all: handles home, public doc pages, and static files. + // Wildcard subtree patterns like /{section}/{slug}/ conflict with every + // other subtree in Go 1.22's mux, so we route public docs manually here. + staticServer := http.FileServer(http.Dir(cfg.StaticDir)) + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + // Static assets live in the static dir under /css/, /js/, /public/. + if strings.HasPrefix(p, "/css/") || strings.HasPrefix(p, "/js/") || strings.HasPrefix(p, "/public/") { + staticServer.ServeHTTP(w, r) + return + } + parts := pathParts(p) + switch len(parts) { + case 0: + h.home(w, r) + case 1: + h.sectionIndex(w, r) + case 2: + h.publicPage(w, r) + default: + http.NotFound(w, r) + } + }) + + addr := ":" + cfg.Port + slog.Info("docs server starting", "addr", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + slog.Error("server error", "err", err) + os.Exit(1) + } +} diff --git a/content/getting-started/email-setup.md b/content/getting-started/email-setup.md new file mode 100644 index 0000000..355893c --- /dev/null +++ b/content/getting-started/email-setup.md @@ -0,0 +1,183 @@ +--- +title: "Set Up Email on Your Domain" +description: "How to create email accounts in cPanel, configure MX records, and connect to Outlook, Apple Mail, or Thunderbird." +section: getting-started +order: 4 +--- + +# Set Up Email on Your Domain + +Arcline hosting includes email hosting for your domain. You can create addresses like `hello@yourdomain.com`, `support@yourdomain.com`, or anything else — and access them via webmail or a mail client. + +--- + +## Create an email account + +In cPanel, go to **Email → Email Accounts** and click **Create**. + +Fill in: + +- **Domain** — select your domain from the dropdown +- **Username** — the part before the `@` (e.g. `hello`) +- **Password** — use a strong password or click **Generate** +- **Storage** — set a mailbox quota (500 MB is fine for most users; set to unlimited only if needed) + +Click **Create**. The account is ready immediately. + +--- + +## Access webmail + +Every Arcline email account comes with webmail. Visit: + +``` +https://your-server.arcline.it/webmail +``` + +Or use the shortcut `https://yourdomain.com/webmail`. Log in with your full email address (`hello@yourdomain.com`) and the password you set. + +Roundcube is the default webmail client. It works in any browser and is good for occasional access. + +--- + +## Connect to Outlook + +1. Open Outlook and go to **File → Add Account**. +2. Enter your full email address and click **Advanced options → Let me set up my account manually**. +3. Choose **IMAP**. + +Fill in the incoming mail settings: + +| Setting | Value | +|---|---| +| Server | `mail.yourdomain.com` | +| Port | `993` | +| Encryption | SSL/TLS | +| Username | your full email address | +| Password | your email password | + +Outgoing mail (SMTP): + +| Setting | Value | +|---|---| +| Server | `mail.yourdomain.com` | +| Port | `465` | +| Encryption | SSL/TLS | +| Username | your full email address | +| Password | your email password | + +Click **Next** then **Done**. Outlook will verify the settings and download your mail. + +> If you see a certificate warning, make sure you're using `mail.yourdomain.com` (not `localhost` or an IP address). Your SSL certificate covers that hostname. + +--- + +## Connect to Apple Mail + +1. Open Mail and go to **Mail → Add Account → Other Mail Account**. +2. Enter your name, full email address, and password. Click **Sign In**. +3. Apple Mail will try to auto-configure. If it fails, enter the server details manually: + +**Incoming:** +- IMAP server: `mail.yourdomain.com` +- Port: `993` +- Use SSL: yes + +**Outgoing:** +- SMTP server: `mail.yourdomain.com` +- Port: `465` +- Use SSL: yes +- Authentication: Password + +Click **Sign In**. Mail will sync your inbox. + +--- + +## Connect to Thunderbird + +1. Open Thunderbird and go to **Account Settings → Account Actions → Add Mail Account**. +2. Enter your name, email address, and password. Click **Configure manually**. + +Fill in: + +| | Incoming (IMAP) | Outgoing (SMTP) | +|---|---|---| +| Server | `mail.yourdomain.com` | `mail.yourdomain.com` | +| Port | `993` | `465` | +| SSL | SSL/TLS | SSL/TLS | +| Auth | Normal password | Normal password | + +Click **Done**. Thunderbird will test and save the account. + +--- + +## Mobile devices (iOS / Android) + +Use the same IMAP/SMTP settings as above. On both iOS and Android: + +1. Go to **Settings → Mail → Accounts → Add Account → Other**. +2. Select **Add Mail Account**. +3. Enter your name, email, and password — then tap **Next**. +4. If auto-configuration fails, enter the incoming and outgoing server details manually (IMAP port 993, SMTP port 465, both SSL). + +--- + +## MX records + +If your domain's DNS is managed at Arcline (using Arcline nameservers), MX records are set automatically — you don't need to change anything. + +If your DNS is managed elsewhere (at your registrar, Cloudflare, etc.), add these MX records manually: + +| Type | Priority | Value | +|---|---|---| +| MX | 0 | `mail.yourdomain.com` | + +Then add an A record pointing `mail.yourdomain.com` to your server's IP address (found in cPanel → **Server Information**). + +DNS changes take 1–48 hours to propagate. While propagating, email sent to your domain may be delayed or bounce. + +--- + +## SPF and DKIM + +SPF and DKIM tell other mail servers that your server is authorized to send email for your domain. Without them, your outgoing email is more likely to be marked as spam. + +**If you're using Arcline nameservers**, SPF and DKIM records are added automatically by cPanel. + +**If you're using external DNS**, you'll need to add them manually. + +**SPF** — add a TXT record to your domain's DNS: + +``` +v=spf1 +a +mx +ip4:YOUR_SERVER_IP ~all +``` + +Replace `YOUR_SERVER_IP` with your server's IP address. + +**DKIM** — in cPanel, go to **Email → Email Deliverability** and click **Manage** next to your domain. Copy the DKIM TXT record shown and add it to your DNS provider. + +After adding both records, go back to **Email → Email Deliverability** and click **Repair** if cPanel flags any problems. + +--- + +## Forwarders + +To forward email from one address to another (e.g., `hello@yourdomain.com` to a Gmail account): + +1. cPanel → **Email → Forwarders → Add Forwarder** +2. Set the source address and destination +3. Click **Add Forwarder** + +You can forward to an external address without creating a full mailbox, which is handy for aliases. + +--- + +## Troubleshooting + +**Can't send email** — check that port 465 (SMTP SSL) isn't blocked by your ISP. Some ISPs block port 25. Try port 587 with STARTTLS as an alternative. + +**Mail going to spam** — verify SPF and DKIM records are set correctly. Check cPanel → **Email → Email Deliverability** for warnings. + +**Authentication failed in mail client** — use your full email address as the username, not just the local part. Confirm the password in cPanel → **Email → Email Accounts → Manage**. + +**Certificate warning** — you must use `mail.yourdomain.com` as the server hostname. Using your server's hostname directly (e.g. `server42.arcline.it`) is fine too as long as it has a valid SSL certificate — check **Email Deliverability** in cPanel for the correct hostname. diff --git a/content/getting-started/mysql-backup.md b/content/getting-started/mysql-backup.md new file mode 100644 index 0000000..d6c2461 --- /dev/null +++ b/content/getting-started/mysql-backup.md @@ -0,0 +1,119 @@ +--- +title: "Back Up and Restore a MySQL Database" +description: "How to export and import MySQL databases on Arcline shared hosting using phpMyAdmin and the command line." +section: getting-started +order: 3 +--- + +# Back Up and Restore a MySQL Database + +Regular database backups are the most important thing you can do to protect your site. A file backup without a database backup is useless for dynamic sites like WordPress. + +--- + +## Export via phpMyAdmin (easiest) + +phpMyAdmin is available in cPanel → **Databases → phpMyAdmin**. + +1. In the left sidebar, click the database name you want to export. +2. Click the **Export** tab at the top. +3. Leave the method as **Quick** and format as **SQL**. +4. Click **Go**. + +The browser will download a `.sql` file. Store it somewhere safe — not on the same server. + +For large databases (over 50 MB), use the **Custom** export method and enable **Add DROP TABLE** so the import won't fail on existing tables. + +--- + +## Export via command line (mysqldump) + +SSH into your server and run: + +```bash +mysqldump -u username -p database_name > backup_$(date +%Y%m%d).sql +``` + +You'll be prompted for the MySQL password. On cPanel servers, the MySQL username is prefixed with your cPanel username: + +```bash +mysqldump -u cpanelusername_dbusername -p cpanelusername_dbname > backup.sql +``` + +Check your cPanel → **Databases → MySQL Databases** for the exact username and database name. + +To compress the backup immediately: + +```bash +mysqldump -u username -p database_name | gzip > backup_$(date +%Y%m%d).sql.gz +``` + +--- + +## Download your backup via SFTP + +After exporting from the command line, download the `.sql` or `.sql.gz` file to your local machine using FileZilla or Cyberduck (see [Upload Files via SFTP](/getting-started/sftp/)). + +The file will be in your home directory: `/home/username/backup.sql`. + +--- + +## Import a database + +### phpMyAdmin + +1. In cPanel → phpMyAdmin, click the target database in the left sidebar. +2. Click the **Import** tab. +3. Click **Choose File** and select your `.sql` file. +4. Click **Go**. + +phpMyAdmin has a file size limit (usually 50–100 MB). For larger databases, use the command line. + +### Command line import + +```bash +mysql -u username -p database_name < backup.sql +``` + +For a compressed backup: + +```bash +gunzip < backup.sql.gz | mysql -u username -p database_name +``` + +> Make sure the target database already exists in cPanel before importing. Create it in cPanel → **Databases → MySQL Databases** if needed, and make sure the database user is assigned to it with all privileges. + +--- + +## Automate daily backups with cron + +SSH in and open your crontab: + +```bash +crontab -e +``` + +Add this line to run a backup every day at 2 AM: + +``` +0 2 * * * mysqldump -u cpanelusername_dbuser -pYOURPASSWORD cpanelusername_dbname | gzip > ~/backups/db_$(date +\%Y\%m\%d).sql.gz +``` + +A few notes: +- Replace `YOURPASSWORD` with your actual MySQL password (no space after `-p`) +- `~/backups/` must exist: `mkdir ~/backups` +- The `\%` escaping is required in crontab + +Also set up a cron to delete backups older than 30 days to avoid filling up disk space: + +``` +30 2 * * * find ~/backups -name "*.sql.gz" -mtime +30 -delete +``` + +cPanel also has a built-in cron interface under **Advanced → Cron Jobs** if you prefer a GUI. + +--- + +## cPanel full backup + +For a complete backup of files **and** databases together, use cPanel → **Files → Backup Wizard**. Choose **Back Up Your Website** and download the full backup. These can be large — only practical for occasional snapshots, not daily automation. diff --git a/content/getting-started/nameservers.md b/content/getting-started/nameservers.md new file mode 100644 index 0000000..be6adb3 --- /dev/null +++ b/content/getting-started/nameservers.md @@ -0,0 +1,140 @@ +--- +title: "Point Your Domain to Arcline" +description: "How to update your nameservers at GoDaddy, Namecheap, Google Domains, and other registrars to point your domain to Arcline hosting." +section: getting-started +order: 5 +--- + +# Point Your Domain to Arcline + +When you register a domain, it points to your registrar's nameservers by default. To use your domain with Arcline hosting, you need to update the nameservers so that DNS queries for your domain are answered by Arcline's servers. + +--- + +## What nameservers do + +Nameservers are the authoritative source for your domain's DNS records. They answer questions like "where does `yourdomain.com` point?" and "what mail server handles `@yourdomain.com`?" + +When you switch to Arcline nameservers: +- Arcline controls all DNS records for your domain +- Your website, email, and subdomains all resolve through Arcline +- Any DNS records you had at your registrar will need to be recreated in cPanel's Zone Editor + +If you want to keep DNS at your registrar (or use Cloudflare), see [Using external DNS](#using-external-dns) below. + +--- + +## Arcline nameservers + +Use these two nameservers for all domains hosted on Arcline: + +``` +ns1.arcline.it +ns2.arcline.it +``` + +Your welcome email also lists these. If you have a reseller or custom nameserver setup, contact support for the correct values. + +--- + +## Update nameservers at GoDaddy + +1. Log in at [godaddy.com](https://godaddy.com) and go to **My Products → Domains**. +2. Click the domain you want to update. +3. Scroll to **Nameservers** and click **Change**. +4. Select **I'll use my own nameservers**. +5. Remove the existing nameservers and enter: + - `ns1.arcline.it` + - `ns2.arcline.it` +6. Click **Save**. + +GoDaddy shows a warning that custom nameservers disable their DNS. That's expected — Arcline will handle DNS instead. + +--- + +## Update nameservers at Namecheap + +1. Log in at [namecheap.com](https://namecheap.com) and go to **Domain List**. +2. Click **Manage** next to your domain. +3. Under **Nameservers**, change the dropdown from **Namecheap BasicDNS** to **Custom DNS**. +4. Enter: + - `ns1.arcline.it` + - `ns2.arcline.it` +5. Click the green checkmark to save. + +--- + +## Update nameservers at Google Domains / Squarespace Domains + +1. Go to [domains.google.com](https://domains.google.com) (or your Squarespace Domains dashboard if it's been migrated). +2. Select your domain and go to **DNS**. +3. At the top, switch from **Google Domains DNS** to **Custom name servers**. +4. Delete the existing entries and add: + - `ns1.arcline.it` + - `ns2.arcline.it` +5. Click **Save**. + +--- + +## Update nameservers at Cloudflare (as registrar) + +If you transferred your domain to Cloudflare as the registrar (not just using Cloudflare for DNS): + +1. Log in to the Cloudflare dashboard and select your account. +2. Go to **Domain Registration → Manage Domains**. +3. Click **Manage** next to your domain, then **Configuration → Name servers**. +4. Select **Use custom nameservers** and enter: + - `ns1.arcline.it` + - `ns2.arcline.it` +5. Save changes. + +> Note: if you're using Cloudflare for DNS only (not as registrar), see [Using external DNS](#using-external-dns) — you'll keep Cloudflare nameservers and point the A record to Arcline instead. + +--- + +## How long does it take? + +DNS propagation typically takes **15 minutes to a few hours**. In rare cases it can take up to 48 hours, depending on your registrar and the TTL on the old records. + +During propagation, some visitors may still see the old hosting while others see the new. This is normal and temporary. + +You can check propagation status at [dnschecker.org](https://dnschecker.org) — enter your domain and select the A record type. Green checkmarks mean that location has the updated DNS. + +--- + +## After switching nameservers + +Once propagation is complete: + +1. **Verify your site loads** — visit your domain in a browser. If it doesn't load immediately, wait a bit longer. +2. **Set up email** — if you were using the registrar's email, you'll need to recreate email accounts in cPanel and add MX records (see [Set Up Email](/getting-started/email-setup/)). +3. **Check SSL** — cPanel's AutoSSL should issue a certificate within a few minutes. If it doesn't, check **cPanel → Security → SSL/TLS Status** (see [SSL Certificate](/getting-started/ssl-certificate/)). + +--- + +## Using external DNS + +If you want to keep DNS management at your registrar or use Cloudflare, don't change your nameservers. Instead, update the A record at your current DNS provider to point to your Arcline server IP: + +| Type | Name | Value | TTL | +|---|---|---|---| +| A | `@` | your Arcline server IP | 300 | +| A | `www` | your Arcline server IP | 300 | + +Find your server IP in cPanel → **Server Information**, or in your Arcline welcome email. + +You'll also need to add MX records and any other DNS records manually. The downside: if you move servers, you have to update your external DNS yourself rather than Arcline handling it automatically. + +--- + +## Subdomain setup + +After switching to Arcline nameservers, you can manage all DNS in cPanel: + +- **cPanel → Domains → Zone Editor** — add, edit, or remove any DNS record +- **cPanel → Domains → Subdomains** — create subdomains that map to subdirectories of your hosting + +Common records to add: +- `www` → your root domain (CNAME or A record, usually already set) +- `mail` → A record pointing to your server IP (needed for email client connections) +- Custom subdomains like `app`, `blog`, `shop` diff --git a/content/getting-started/sftp.md b/content/getting-started/sftp.md new file mode 100644 index 0000000..523423a --- /dev/null +++ b/content/getting-started/sftp.md @@ -0,0 +1,113 @@ +--- +title: "Upload Files via SFTP" +description: "How to connect to your Arcline server with FileZilla or Cyberduck and transfer website files." +section: getting-started +order: 2 +--- + +# Upload Files via SFTP + +SFTP (SSH File Transfer Protocol) lets you upload, download, and manage files on your server using a graphical client. It's encrypted and the standard method for file transfers — FTP without TLS is not supported on Arcline servers. + +## What you'll need + +- Your server's hostname or IP address +- Your cPanel username and password (or an SSH key) +- SFTP port: **2222** (cPanel default) + +--- + +## FileZilla + +FileZilla is free, cross-platform, and works well for most file transfer tasks. + +**Download:** [filezilla-project.org](https://filezilla-project.org) — use the regular FileZilla Client, not FileZilla Pro. + +### Quick connect + +In the toolbar at the top, fill in: + +- **Host:** `sftp://your-server.arcline.it` +- **Username:** your cPanel username +- **Password:** your cPanel password +- **Port:** `2222` + +Click **Quickconnect**. Accept the server fingerprint when prompted. + +### Site Manager (recommended for saved connections) + +Go to **File → Site Manager → New Site** and fill in: + +| Field | Value | +|---|---| +| Protocol | SFTP – SSH File Transfer Protocol | +| Host | `your-server.arcline.it` | +| Port | `2222` | +| Logon Type | Normal | +| User | your cPanel username | +| Password | your cPanel password | + +Click **Connect**. The site is saved for next time. + +### Navigating your files + +- **Left panel** — your local computer +- **Right panel** — your server + +Your website files live in `/home/username/public_html/`. Navigate there on the right side before uploading. + +To upload: drag files from the left panel to the right. To download: drag from right to left. + +> **Before uploading a new version of your site**, download a copy of `public_html` first so you have a backup. + +--- + +## Cyberduck + +Cyberduck is a good option on macOS (also available on Windows). + +**Download:** [cyberduck.io](https://cyberduck.io) — it's free, though they ask for a donation. + +Click **Open Connection** and set: + +| Field | Value | +|---|---| +| Protocol | SFTP (SSH File Transfer Protocol) | +| Server | `your-server.arcline.it` | +| Port | `2222` | +| Username | your cPanel username | +| Password | your cPanel password | + +Click **Connect**. Bookmark the connection (⌘D on Mac) to save it. + +--- + +## SSH key authentication + +If you've set up SSH keys (see [Connect via SSH](/getting-started/ssh/)), both FileZilla and Cyberduck support key-based login: + +**FileZilla:** In Site Manager, set Logon Type to **Key file** and point it to your private key file (`~/.ssh/id_ed25519`). + +**Cyberduck:** In Open Connection, leave the password blank and check **Use Public Key Authentication**. Cyberduck will pick up keys from `~/.ssh/` automatically. + +--- + +## Common paths + +| What | Path | +|---|---| +| Your website root | `/home/username/public_html/` | +| WordPress uploads | `/home/username/public_html/wp-content/uploads/` | +| cPanel mail | `/home/username/mail/` | +| cPanel logs | `/home/username/logs/` | +| cPanel backups | `/home/username/backup/` | + +--- + +## Troubleshooting + +**Authentication failed** — double-check your username, password, and port (2222, not 22). Make sure you're using SFTP not FTP. + +**Connection timed out** — a firewall or fail2ban may be blocking your IP. Try connecting from a different network to test, then contact support. + +**Permission denied when uploading** — the target directory may be owned by a different user or have restrictive permissions. Check the permissions in FileZilla (right-click → File permissions) or via SSH: `ls -la /home/username/public_html/`. diff --git a/content/getting-started/ssh.md b/content/getting-started/ssh.md new file mode 100644 index 0000000..c0950dc --- /dev/null +++ b/content/getting-started/ssh.md @@ -0,0 +1,122 @@ +--- +title: "Connect to Your Server via SSH" +description: "How to SSH into your Arcline server on Windows, macOS, and Linux — plus setting up SSH keys for passwordless login." +section: getting-started +order: 1 +--- + +# Connect to Your Server via SSH + +SSH (Secure Shell) is the standard way to access your Arcline server's command line. Once connected you can manage files, install software, restart services, and run anything you'd run locally. + +## What you'll need + +- Your server's hostname or IP address (from your welcome email) +- Your SSH username (typically the cPanel username) +- Your SSH password **or** an SSH key pair + +--- + +## Connect on macOS or Linux + +Open Terminal and run: + +```bash +ssh username@your-server.arcline.it +``` + +Replace `username` with your cPanel username and `your-server.arcline.it` with your server hostname. You'll be prompted for your password on first login. + +If your server runs SSH on a non-standard port (common with shared hosting — cPanel uses port **2222**): + +```bash +ssh -p 2222 username@your-server.arcline.it +``` + +--- + +## Connect on Windows + +Windows 10 and 11 include OpenSSH built in. Open **PowerShell** or **Windows Terminal** and run the same command: + +```powershell +ssh username@your-server.arcline.it +``` + +Or with port 2222: + +```powershell +ssh -p 2222 username@your-server.arcline.it +``` + +**Using PuTTY** — if you prefer a GUI client, download PuTTY from [putty.org](https://www.putty.org). Fill in: + +- Host Name: `your-server.arcline.it` +- Port: `2222` +- Connection type: SSH + +Click **Open**, accept the server fingerprint on first connection, and log in with your username and password. + +--- + +## Set up SSH key authentication + +SSH keys are more secure than passwords and let you log in without typing a password every time. + +**Step 1 — Generate a key pair** (skip if you already have one at `~/.ssh/id_ed25519`): + +```bash +ssh-keygen -t ed25519 -C "your@email.com" +``` + +Accept the default file location. Set a passphrase if you want extra protection. + +**Step 2 — Copy your public key to the server:** + +```bash +ssh-copy-id -p 2222 username@your-server.arcline.it +``` + +Or manually: log in, open `~/.ssh/authorized_keys` on the server, and paste the contents of your local `~/.ssh/id_ed25519.pub` file. + +**Step 3 — Verify it works:** + +```bash +ssh -p 2222 username@your-server.arcline.it +``` + +You should log in without being asked for a password. + +**Via cPanel** — you can also manage SSH keys in **cPanel → Security → SSH Access**. Upload your public key there and cPanel will authorize it automatically. + +--- + +## Simplify repeated connections + +Add an entry to `~/.ssh/config` to avoid typing the full command every time: + +``` +Host arcline + HostName your-server.arcline.it + User username + Port 2222 + IdentityFile ~/.ssh/id_ed25519 +``` + +Now you can connect with just: + +```bash +ssh arcline +``` + +--- + +## Common errors + +**Connection refused** — the SSH service may be running on a different port, or a firewall is blocking the connection. Confirm the port with Arcline support. + +**Permission denied (publickey)** — your key isn't authorized on the server. Check that `~/.ssh/authorized_keys` contains your public key and has permissions `600`. + +**Host key verification failed** — the server's fingerprint changed (common after a reinstall). Remove the old entry: `ssh-keygen -R your-server.arcline.it` and reconnect. + +**Connection timed out** — your IP may be temporarily blocked by fail2ban after too many failed attempts. Contact support to whitelist your IP. diff --git a/content/getting-started/ssl-certificate.md b/content/getting-started/ssl-certificate.md new file mode 100644 index 0000000..8891a38 --- /dev/null +++ b/content/getting-started/ssl-certificate.md @@ -0,0 +1,131 @@ +--- +title: "SSL Certificate and HTTPS" +description: "How to enable SSL on your Arcline domain, force HTTPS, fix mixed content warnings, and troubleshoot certificate problems." +section: getting-started +order: 6 +--- + +# SSL Certificate and HTTPS + +Every Arcline hosting account includes free SSL certificates via AutoSSL (powered by Let's Encrypt). Certificates are issued automatically for your domain and renewed every 90 days without any action required on your part. + +--- + +## Check your SSL status + +In cPanel, go to **Security → SSL/TLS Status**. You'll see a list of all domains on your account and their certificate status: + +- **Certificate valid** — green, working +- **Certificate expiring soon** — will auto-renew before it expires +- **Failed** — usually a DNS problem; see [Troubleshooting](#troubleshooting) below + +If AutoSSL hasn't run yet after you pointed your domain to Arcline, click **Run AutoSSL** at the top of the page to trigger it immediately. + +--- + +## Force HTTPS + +Having an SSL certificate doesn't automatically redirect `http://` to `https://`. You need to add a redirect so all visitors get the secure version. + +### WordPress + +Install the **Really Simple SSL** plugin — it handles the redirect and fixes most mixed content issues in one click. It's free and widely trusted. + +Or add this to the top of your `.htaccess` file (before the `# BEGIN WordPress` block): + +```apache +RewriteEngine On +RewriteCond %{HTTPS} off +RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] +``` + +### Non-WordPress sites + +Add the same redirect to `.htaccess` in your document root (`/home/username/public_html/.htaccess`): + +```apache +RewriteEngine On +RewriteCond %{HTTPS} off +RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] +``` + +### Via cPanel + +cPanel has a built-in redirect tool: go to **Domains → Redirects**, set type to **Permanent (301)**, enter `http://yourdomain.com` as source, `https://yourdomain.com` as destination. Repeat for `www`. + +Alternatively, cPanel → **Security → SSL/TLS** → **Manage SSL Sites** has a "Force HTTPS Redirect" checkbox for domains with an active certificate. + +--- + +## WordPress mixed content + +After switching to HTTPS, WordPress sometimes still loads some resources over HTTP — images, scripts, or stylesheets embedded in post content. This causes browser warnings ("Not Secure" or padlock with warning). + +**Quick fix:** Really Simple SSL catches most of these automatically. + +**Manual fix for URLs stored in the database:** + +Use the **Better Search Replace** plugin to change `http://yourdomain.com` to `https://yourdomain.com` across all tables. Run it in dry-run mode first to see what would change. + +Or via WP-CLI over SSH: + +```bash +wp search-replace 'http://yourdomain.com' 'https://yourdomain.com' --all-tables +``` + +After replacing, go to **Settings → General** and make sure both WordPress Address and Site Address are set to `https://`. + +--- + +## Let's Encrypt certificate details + +Arcline uses Let's Encrypt certificates: + +- **Validity:** 90 days (renewed automatically ~30 days before expiry) +- **Covers:** your domain and `www.yourdomain.com` (and other configured domains/subdomains on the same account) +- **CA:** Let's Encrypt (trusted by all modern browsers) +- **Cost:** free, included with all Arcline hosting plans + +There's no manual renewal step. cPanel's AutoSSL daemon runs nightly and renews certificates that are within 30 days of expiry. + +--- + +## Installing a third-party certificate + +If you have a purchased certificate (e.g., an Extended Validation or wildcard cert from a commercial CA), you can install it manually: + +1. cPanel → **Security → SSL/TLS → Manage SSL Sites** +2. Select your domain from the dropdown +3. Paste the certificate (`.crt`) and private key (`.key`) into the fields +4. Paste the CA bundle / intermediate certificate if required by your CA +5. Click **Install Certificate** + +The Let's Encrypt certificate will be replaced. AutoSSL won't overwrite a manually installed certificate that isn't yet expired. + +--- + +## Troubleshooting + +### Certificate not issued + +AutoSSL requires that your domain resolve to your Arcline server's IP before it can issue a certificate. Common causes of failure: + +1. **DNS hasn't propagated** — wait up to 24 hours after pointing nameservers, then run AutoSSL again +2. **Using external DNS with wrong A record** — verify that `yourdomain.com` and `www.yourdomain.com` A records point to your Arcline server IP +3. **CAA record blocking Let's Encrypt** — if you have a CAA record that doesn't include `letsencrypt.org`, Let's Encrypt can't issue. Add: `0 issue "letsencrypt.org"` or remove the CAA record + +### ERR_SSL_PROTOCOL_ERROR or site not loading over HTTPS + +Your certificate may not be installed yet. Check **SSL/TLS Status** in cPanel and run AutoSSL if needed. Temporarily access the site over `http://` while waiting. + +### Certificate valid but browser shows "Not Secure" + +Mixed content — some resources are still loading over HTTP. See [WordPress mixed content](#wordpress-mixed-content) above. + +### Certificate expired + +AutoSSL should prevent this, but if it fails repeatedly (usually due to DNS issues), the certificate can expire. Fix the underlying DNS problem, then run AutoSSL manually in cPanel. If the domain is unreachable from outside, AutoSSL cannot complete the Let's Encrypt challenge. + +### HSTS issues after switching back to HTTP + +If your site previously sent `Strict-Transport-Security` (HSTS) headers, browsers will refuse to load it over HTTP even if you remove SSL. This is the HSTS preload taking effect. The fix is to restore the certificate — don't remove SSL from a site that had HSTS enabled. diff --git a/content/legal/dpa.md b/content/legal/dpa.md new file mode 100644 index 0000000..9c9d1c7 --- /dev/null +++ b/content/legal/dpa.md @@ -0,0 +1,242 @@ +# Data Processing Agreement (DPA) + +**Arcline IT LLC** +Last updated: May 2026 + +This Data Processing Agreement ("DPA") forms part of the Master Service +Agreement ("MSA") between Arcline IT LLC ("Data Processor", "Arcline", "we", +"us") and the Customer ("Data Controller", "you"). + +--- + +## 1. Definitions + +| Term | Definition | +|------|------------| +| **Controller** | The entity that determines the purposes and means of processing personal data | +| **Processor** | The entity that processes personal data on behalf of the Controller | +| **Data Subject** | An identified or identifiable natural person | +| **Personal Data** | Any information relating to an identified or identifiable natural person | +| **Processing** | Any operation performed on personal data (collection, storage, retrieval, transmission, deletion, etc.) | +| **GDPR** | Regulation (EU) 2016/679, the General Data Protection Regulation | +| **CCPA** | California Consumer Privacy Act, as amended | +| **Sub-processor** | A third party engaged by the Processor to process personal data | + +--- + +## 2. Scope and Purpose + +### 2.1 Application +This DPA applies whenever Arcline processes personal data on behalf of +Customer in the course of providing Services under the MSA. + +### 2.2 Relationship +- **Customer** is the Data Controller +- **Arcline** is the Data Processor +- Customer retains full control over their personal data +- Arcline processes data only on Customer's documented instructions + +### 2.3 Duration +This DPA remains in effect for as long as Arcline processes personal data on +behalf of Customer, plus the duration of any data retention obligations. + +--- + +## 3. Description of Processing + +### 3.1 Categories of Data Subjects +- Customer's employees, contractors, and agents +- Customer's end users and website visitors +- Individuals who communicate with Customer through their Arcline-hosted services + +### 3.2 Categories of Personal Data +- Account information: name, email address, billing address, phone number +- Technical data: IP addresses, server access logs, browser user-agent strings +- Content data: files, databases, emails, and other content stored on Arcline + infrastructure at Customer's direction +- Payment data: processed through Stripe (PCI-DSS compliant); Arcline does + not store full credit card numbers + +### 3.3 Special Categories of Data +Arcline does not intentionally process special categories of data (health +information, biometric data, political opinions, religious beliefs, etc.). +Customer agrees not to upload special category data to Arcline infrastructure +without additional contractual safeguards. + +### 3.4 Processing Activities +- **Storage:** Customer data stored on Arcline's servers +- **Hosting:** Serving Customer's websites and applications to visitors +- **Backup:** Creating and maintaining backup copies for disaster recovery +- **Email:** Routing and storing email messages (where applicable) +- **Support:** Accessing data for troubleshooting and support purposes + +--- + +## 4. Processor Obligations + +### 4.1 Instructions +Arcline will process personal data only on documented instructions from +Customer, unless required to do otherwise by applicable law (in which case +Arcline will notify Customer of that legal requirement before processing, +unless prohibited by law). + +### 4.2 Confidentiality +Arcline ensures that all personnel authorized to process personal data have +committed to confidentiality obligations. + +### 4.3 Security +Arcline maintains appropriate technical and organizational security measures, +including: + +**Technical Measures:** +- Encryption in transit (TLS 1.2+ for all services) +- Firewalls with default-deny rules +- Network segmentation (VLANs) +- Regular security patching +- Intrusion detection and prevention systems (Suricata) +- Access logging and monitoring +- Encrypted off-site backups + +**Organizational Measures:** +- Access control based on least privilege +- Security training for personnel +- Incident response procedures +- Regular security assessments +- Vendor due diligence for sub-processors + +### 4.4 Sub-processors +Customer authorizes Arcline to engage the following sub-processors: + +| Sub-processor | Service | Location | +|---------------|---------|----------| +| Stripe, Inc. | Payment processing | United States | +| Let's Encrypt / ISRG | SSL certificate issuance | United States | +| GitLab B.V. | CI/CD and source control | United States/Europe | + +Arcline will notify Customer of any intended changes to sub-processors. +Customer may object within 14 days. If reasonable objections cannot be +resolved, Customer may terminate the affected services. + +### 4.5 Data Subject Rights +Arcline will assist Customer in responding to data subject requests under +applicable privacy laws, including: +- Right of access +- Right to rectification +- Right to erasure ("right to be forgotten") +- Right to restrict processing +- Right to data portability +- Right to object + +Customer should forward any data subject requests they receive to +**privacy@arcline.it**. Arcline will respond within the timeframe required +by applicable law. + +### 4.6 Data Breach Notification +In the event of a personal data breach, Arcline will: +1. Notify Customer within 72 hours of becoming aware of the breach +2. Provide details of the nature, scope, and impact of the breach +3. Describe measures taken to address the breach +4. Cooperate with Customer in notifying supervisory authorities and affected + data subjects, where required + +### 4.7 Data Protection Impact Assessments +Arcline will provide reasonable assistance to Customer in conducting data +protection impact assessments, where required by applicable law. + +--- + +## 5. International Transfers + +### 5.1 Data Location +Customer data is primarily stored on servers located in the United States. + +### 5.2 Adequacy +For transfers of personal data from the European Economic Area (EEA), +Switzerland, or the United Kingdom to the United States, the parties agree +that the Standard Contractual Clauses (SCCs) approved by the European +Commission shall govern such transfers. + +### 5.3 Alternative Mechanism +If the SCCs are deemed invalid or insufficient by a competent authority, +Arcline will implement an alternative lawful transfer mechanism. + +--- + +## 6. Data Retention and Deletion + +### 6.1 During the Term +Customer data is retained for the duration of the MSA or until Customer +requests deletion. + +### 6.2 Upon Termination +Following termination of the MSA: +- **Active data:** Deleted within 30 days of termination +- **Backups:** Deleted within 90 days of termination +- **Access logs:** Anonymized or deleted within 12 months + +### 6.3 Deletion Procedures +Data is securely deleted using: +- Secure file deletion (shred/overwrite) for files +- `DROP TABLE` for SQLite databases +- Cryptographic erasure for encrypted backups + +### 6.4 Certificate of Deletion +Upon request, Arcline will provide a written certificate confirming that +Customer's data has been securely deleted. + +--- + +## 7. Audit and Compliance + +### 7.1 Right to Audit +Customer may request an audit of Arcline's data processing operations, at +Customer's expense, no more than once per 12-month period. Audits must: +- Be conducted during normal business hours +- Give at least 30 days notice +- Not unreasonably interfere with Arcline's operations +- Be performed by a mutually agreed independent auditor + +### 7.2 Records of Processing +Arcline maintains written records of all processing activities conducted on +behalf of Customer, as required by Article 30 of the GDPR. + +### 7.3 Compliance +Arcline will promptly notify Customer if any instruction from Customer +violates applicable data protection laws. + +--- + +## 8. Liability + +### 8.1 Liability Cap +Each party's liability under this DPA is subject to the limitations of +liability set forth in the MSA. + +### 8.2 Direct Damages +Notwithstanding the general limitation above, either party may seek direct +damages for breaches of this DPA. + +### 8.3 Regulatory Fines +Each party is responsible for administrative fines imposed on them by a +supervisory authority for their own violations of applicable data protection +law. + +--- + +## 9. Governing Law + +This DPA shall be governed by the same law as the MSA. Any dispute arising +from this DPA shall be resolved under the dispute resolution provisions of +the MSA. + +--- + +## 10. Order of Precedence + +In the event of any conflict or inconsistency between this DPA and the MSA, +this DPA shall prevail with respect to data processing matters. + +--- + +*Questions about this DPA? Contact us at privacy@arcline.it* + diff --git a/content/legal/msa.md b/content/legal/msa.md new file mode 100644 index 0000000..10f933b --- /dev/null +++ b/content/legal/msa.md @@ -0,0 +1,317 @@ +# Master Service Agreement (MSA) + +**Arcline IT LLC** +Last updated: May 2026 + +--- + +## 1. Parties + +This Master Service Agreement ("Agreement") is between **Arcline IT LLC** +("Arcline", "Provider", "we", "us") and the customer named in the applicable +Order Form ("Customer", "you"). This Agreement governs all Services provided +by Arcline to Customer. + +--- + +## 2. Services + +### 2.1 Service Offerings +Arcline provides the following hosting services ("Services"): +- **Shared Web Hosting** — Multi-tenant web server with cPanel control panel +- **WordPress Hosting** — Managed WordPress environment +- **VPS Hosting** — Virtual private servers with root access +- **Domain Registration** — Domain name registration and management +- **SSL Certificates** — Let's Encrypt SSL certificate provisioning +- **Email Hosting** — Self-hosted email services (where available) + +### 2.2 Service Levels +Services are provided in accordance with our [Service Level Agreement (SLA)](sla.md), +which is incorporated by reference into this Agreement. + +### 2.3 Changes to Services +Arcline may modify, upgrade, or discontinue specific service offerings with +30 days written notice to Customer. In the event of service discontinuation, +Customer will receive a pro-rata refund for any prepaid but unused Service +fees. + +--- + +## 3. Term and Termination + +### 3.1 Initial Term +The initial term of this Agreement begins on the date Customer accepts these +terms (by signing an Order Form or creating an Arcline account) and continues +for the duration of the initial billing period selected in the Order Form. + +### 3.2 Renewal +This Agreement automatically renews for successive billing periods of equal +length unless either party provides written notice of non-renewal at least +7 days before the end of the current term. + +### 3.3 Termination for Convenience +Customer may terminate this Agreement at any time from the client portal or +by contacting support. Services continue until the end of the current billing +period. No refunds are provided for partial months, except as stated in +Section 2.3. + +### 3.4 Termination for Cause +Either party may terminate this Agreement immediately upon written notice if: +- The other party materially breaches this Agreement and fails to cure the + breach within 7 days of receiving written notice +- The other party becomes insolvent, files for bankruptcy, or ceases operations + +### 3.5 Effects of Termination +Upon termination: +- Customer's access to Services ceases +- Arcline will delete Customer's data after the data preservation period + (14 days for shared hosting, 7 days for VPS) +- Outstanding invoices become immediately due and payable + +--- + +## 4. Fees and Payment + +### 4.1 Fees +Customer agrees to pay the fees specified in the Order Form. All fees are +in United States Dollars (USD). Fees do not include taxes, which are +Customer's responsibility. + +### 4.2 Invoicing +Fees are billed in advance on a monthly or annual basis as selected in the +Order Form. Invoices are generated on the billing date and sent by email. + +### 4.3 Payment Terms +Payment is due upon receipt of invoice. Accounts more than 7 days past due +may be suspended. Suspended accounts are held for 14 days before data is +deleted. + +### 4.4 Price Changes +Arcline may change service pricing with 30 days written notice. Price +increases will not exceed 10% annually unless required by changes in +underlying infrastructure costs. + +### 4.5 Refunds +- **Monthly plans:** Non-refundable after the billing cycle begins +- **Annual plans:** Pro-rata refund available within the first 30 days +- **Setup fees:** No setup fees are charged + +--- + +## 5. Customer Responsibilities + +### 5.1 Account Security +Customer is responsible for: +- Maintaining the confidentiality of login credentials +- All activity occurring under their account +- Promptly notifying Arcline of any suspected unauthorized access + +### 5.2 Acceptable Use +Customer must comply with the [Acceptable Use Policy (AUP)](https://arcline.it/aup), +which is incorporated by reference. Violation of the AUP may result in +immediate suspension without refund. + +### 5.3 Data Backup +Customer is responsible for maintaining independent backups of their data. +Arcline performs routine backups for disaster recovery purposes but does not +guarantee data availability in all scenarios. VPS customers are solely +responsible for their own backup strategy. + +### 5.4 Compliance +Customer represents and warrants that: +- Their content and use of Services complies with all applicable laws +- They hold all necessary rights and permissions for content stored on + Arcline infrastructure +- They will not use Services to violate the rights of others + +--- + +## 6. Provider Responsibilities + +### 6.1 Service Delivery +Arcline will provide Services in accordance with this Agreement and the SLA. + +### 6.2 Security +Arcline will maintain industry-standard physical and network security measures, +including: +- Firewall protection with default-deny rules +- Regular security updates and patching +- Encrypted data transmission (TLS 1.2+) +- Secure configuration of all servers and network equipment + +### 6.3 Privacy +Arcline will not access Customer's files or data except: +- To perform maintenance or troubleshooting +- To investigate suspected AUP violations +- To comply with valid legal process + +### 6.4 Incident Notification +Arcline will notify Customer of any security incident involving Customer's +data within 72 hours of becoming aware of the incident. + +--- + +## 7. Intellectual Property + +### 7.1 Customer Content +As between the parties, Customer retains all intellectual property rights in +the content, data, and applications they store or process using Arcline's +Services. + +### 7.2 Arcline IP +Arcline retains all rights in its proprietary software, infrastructure, +trademarks, and branding. This Agreement does not grant Customer any license +to Arcline's intellectual property beyond what is necessary to use the +Services. + +### 7.3 Feedback +Any suggestions, feedback, or feature requests Customer provides may be used +by Arcline without obligation or compensation. + +--- + +## 8. Confidentiality + +### 8.1 Definition +"Confidential Information" means any non-public information disclosed by one +party to the other, whether written, oral, or electronic, that is designated +as confidential or reasonably should be understood to be confidential. + +### 8.2 Obligations +Each party agrees to: +- Use Confidential Information only for purposes of this Agreement +- Protect Confidential Information using reasonable care +- Not disclose Confidential Information to third parties without written + consent, except to employees and contractors with a need to know + +### 8.3 Exclusions +Confidential Information does not include information that: +- Is or becomes publicly available through no fault of the receiving party +- Was already known to the receiving party prior to disclosure +- Is independently developed by the receiving party +- Is required to be disclosed by law + +--- + +## 9. Limitation of Liability + +### 9.1 No Indirect Damages +Neither party shall be liable for any indirect, incidental, special, +consequential, or punitive damages, including lost profits, lost revenue, +lost data, or business interruption, even if advised of the possibility of +such damages. + +### 9.2 Cap on Liability +Each party's total liability to the other for all claims arising under this +Agreement shall not exceed the total fees paid by Customer to Arcline in the +12 months preceding the claim. + +### 9.3 Exceptions +Nothing in this section limits either party's liability for: +- Death or personal injury caused by negligence +- Fraud or willful misconduct +- Breach of confidentiality obligations +- Intellectual property infringement + +--- + +## 10. Indemnification + +### 10.1 Customer Indemnity +Customer agrees to indemnify and hold harmless Arcline from any claims, +damages, or expenses arising from: +- Customer's breach of this Agreement +- Customer's violation of applicable law +- Customer's content that infringes third-party rights + +### 10.2 Procedure +The indemnified party must: +- Provide prompt written notice of the claim +- Allow the indemnifying party to control the defense +- Provide reasonable cooperation in the defense + +--- + +## 11. Data Processing + +### 11.1 Data Processor +To the extent Customer provides Arcline with personal data (as defined by +applicable privacy laws), Customer is the data controller and Arcline is the +data processor. Our [Data Processing Agreement (DPA)](dpa.md) governs such +processing and is incorporated by reference. + +### 11.2 Data Location +Customer data is stored on servers located in the United States. Arcline does +not transfer data to other jurisdictions without Customer's consent. + +--- + +## 12. Governing Law and Disputes + +### 12.1 Governing Law +This Agreement shall be governed by and construed in accordance with the laws +of the United States and the State of Oklahoma. + +### 12.2 Dispute Resolution +Any dispute arising from this Agreement shall first be attempted to be resolved +through good-faith negotiations. If not resolved within 30 days, disputes may +be submitted to binding arbitration in accordance with the rules of the +American Arbitration Association. + +### 12.3 Legal Fees +In any action to enforce this Agreement, the prevailing party shall be +entitled to recover reasonable legal fees and costs. + +--- + +## 13. General Provisions + +### 13.1 Entire Agreement +This Agreement, together with the Order Form, [SLA](sla.md), +[AUP](https://arcline.it/aup), [Privacy Policy](https://arcline.it/privacy), +and [DPA](dpa.md), constitutes the entire agreement between the parties +regarding the subject matter. + +### 13.2 Amendments +Arcline may amend this Agreement with 14 days written notice. Continued use +of Services after the effective date constitutes acceptance. + +### 13.3 Assignment +Customer may not assign this Agreement without Arcline's written consent. +Arcline may assign this Agreement in connection with a merger, acquisition, +or sale of assets. + +### 13.4 Severability +If any provision of this Agreement is found to be unenforceable, the +remaining provisions shall remain in full force and effect. + +### 13.5 Waiver +Failure to enforce any provision of this Agreement shall not constitute a +waiver of that provision. + +### 13.6 No Third-Party Beneficiaries +This Agreement is for the sole benefit of the parties and their permitted +assigns and does not confer any rights on third parties. + +### 13.7 Notices +All legal notices under this Agreement shall be sent in writing to: +- **Arcline IT LLC** — by email to legal@arcline.it +- **Customer** — to the email address on file in the customer portal + +--- + +## 14. Definitions + +| Term | Definition | +|------|------------| +| **Order Form** | The service order, plan selection, or checkout process through which Customer selects specific Services | +| **Services** | Hosting and related services provided by Arcline under this Agreement | +| **SLA** | Service Level Agreement, available at [docs.arclineit.com/legal/sla] | +| **AUP** | Acceptable Use Policy, available at [arcline.it/aup](https://arcline.it/aup) | +| **DPA** | Data Processing Agreement, available at [docs.arclineit.com/legal/dpa] | + +--- + +*To accept this Agreement, create an account or sign the applicable Order +Form. Questions? Contact us at [arcline.it/contact](https://arcline.it/contact)* + diff --git a/content/legal/sla.md b/content/legal/sla.md new file mode 100644 index 0000000..3a827a4 --- /dev/null +++ b/content/legal/sla.md @@ -0,0 +1,183 @@ +# Service Level Agreement (SLA) + +**Arcline IT LLC** +Last updated: May 2026 +Applies to: All Arcline shared hosting, WordPress hosting, and VPS hosting services. + +--- + +## 1. Overview + +This Service Level Agreement ("SLA") governs the availability and performance +of services provided by Arcline IT LLC ("Arcline", "we", "us") to you ("Customer"). +This SLA is incorporated by reference into the Arcline Terms of Service and +Master Service Agreement. + +Arcline operates self-hosted infrastructure on owned hardware. We do not use +AWS, Azure, GCP, Cloudflare, or any hyperscale cloud provider. We are +transparent about what this means for availability, and we believe honesty +about our capabilities serves our customers better than inflated promises. + +--- + +## 2. Uptime Commitment + +| Service Tier | Monthly Uptime Target | Annual Uptime Target | +|-------------|----------------------|---------------------| +| Shared Hosting | 99.0% | 98.5% | +| WordPress Hosting | 99.0% | 98.5% | +| VPS Hosting | 99.5% | 99.0% | +| Network (edge) | 99.5% | 99.0% | + +**Calculated as:** +``` +Uptime % = (Total Minutes in Month − Downtime Minutes) ÷ Total Minutes × 100 +``` + +--- + +## 3. Service Credits + +If we fail to meet the uptime target in a given calendar month, you may request +a service credit. Credits are applied to your next invoice and are not +redeemable for cash. + +| Monthly Uptime | Credit | +|----------------|--------| +| Below target but ≥ 95% | 10% of monthly fee | +| 90% – 94.99% | 25% of monthly fee | +| Below 90% | 50% of monthly fee | + +### How to Request a Credit + +1. Email **support@arcline.it** with subject line "SLA Credit Request" +2. Include your account email and the month in question +3. We will verify our monitoring data and respond within 5 business days + +Credits must be requested within 30 days of the end of the month in which +the downtime occurred. + +**Limitation:** Total credits in any single month shall not exceed the +Customer's monthly service fee for the affected service. + +--- + +## 4. Exclusions + +The following are excluded from SLA calculations and are not eligible for +service credits: + +### Scheduled Maintenance +- Announced maintenance windows with at least 48 hours notice +- Emergency security patches (notice as circumstances permit) +- We schedule maintenance during off-peak hours (midnight–6 AM ET) when possible + +### Customer-Caused Downtime +- Configuration errors by the Customer +- Exceeding resource limits (CPU, RAM, disk I/O, bandwidth) +- Failure to maintain payment on the account (past-due accounts) +- Actions taken by the Customer that trigger abuse prevention mechanisms + +### Force Majeure +- Natural disasters, war, civil unrest, pandemic, or other events beyond + reasonable control +- Upstream network outages (BGP issues, transit provider failures) — we will + work to mitigate but do not guarantee alternate routing +- Power outages at our facility — we maintain UPS and generator backup but + do not guarantee 100% uptime + +### Third-Party Services +- DNS propagation delays when changing nameservers +- SSL certificate issuance delays by Let's Encrypt or other CAs +- Email deliverability issues caused by receiver-side filtering + +### VPS-Specific Exclusions +- Guest operating system crashes or misconfiguration +- Resource exhaustion caused by the Customer's workload +- Actions taken by the Customer's users + +--- + +## 5. Monitoring + +Arcline measures uptime using our internal monitoring system (`arcline-uptime`), +which performs checks from multiple locations at 1-minute intervals. Monitoring +results are available at [status.arclineit.com](https://status.arclineit.com). + +We monitor: +- **HTTP services** — connection success, HTTP 200 status, response within 10s +- **TCP services** — successful TCP connection to service port +- **Network** — ping response from edge router +- **Infrastructure** — system load, disk usage, temperature sensors + +In the event of a disagreement about uptime, Arcline's monitoring data shall +be the primary source. Customers are encouraged to run independent monitoring +and may submit their own monitoring data for consideration. + +--- + +## 6. Maintenance Windows + +### Routine Maintenance +- Typically performed Tuesday–Thursday between midnight and 6 AM ET +- Announced on [status.arclineit.com](https://status.arclineit.com) at least + 48 hours in advance +- Brief service interruptions (< 15 minutes) for routine updates + +### Emergency Maintenance +- Security vulnerabilities (CVSS ≥ 7.0): patched within 24 hours +- Critical hardware failures: immediate intervention +- Notice provided via status page and email as time permits + +--- + +## 7. Incident Response + +| Severity | Definition | Initial Response | Update Frequency | +|----------|-----------|-----------------|------------------| +| **Critical** | Service unavailable, all customers affected | 30 minutes | Every 60 minutes | +| **Major** | Service degraded or partially unavailable | 1 hour | Every 2 hours | +| **Minor** | Isolated issue affecting few customers | 2 hours | Every 4 hours | +| **Maintenance** | Planned work with brief interruption | Per schedule | Per schedule | + +Response times are measured from the time Arcline becomes aware of the issue +(automated alert or customer report), not from the start of the incident. + +--- + +## 8. Support Response Times + +| Priority | Channel | Target Response | +|----------|---------|-----------------| +| Emergency (service down) | Ticket + email | 30 minutes (business hours) | +| High (degraded service) | Ticket | 2 hours (business hours) | +| Normal (general inquiry) | Ticket | 24 hours | +| Low (feature request) | Ticket | 48 hours | + +**Business hours:** Monday–Friday, 9 AM–6 PM ET. +**After hours:** Best-effort for Critical and Major incidents only. + +--- + +## 9. Data Preservation + +In the event of account suspension or termination: +- **Shared/WordPress hosting:** Data preserved for 14 days after suspension +- **VPS hosting:** Data preserved for 7 days after suspension +- **Final deletion:** Data is securely erased after the preservation period + +We do not provide data recovery for accounts that have been deleted for more +than 30 days. + +--- + +## 10. SLA Exceptions and Changes + +Arcline reserves the right to modify this SLA with 30 days written notice to +active customers. Material changes will be emailed and posted to our status +page. + +--- + +*Questions about this SLA? Contact us at [arcline.it/contact](https://arcline.it/contact)* + diff --git a/content/migrate/from-bluehost.md b/content/migrate/from-bluehost.md new file mode 100644 index 0000000..4aa500f --- /dev/null +++ b/content/migrate/from-bluehost.md @@ -0,0 +1,184 @@ +--- +title: "Migrate from Bluehost" +description: "How to move your website and email from Bluehost to Arcline, including WordPress sites and cPanel-to-cPanel transfers." +section: migrate +order: 2 +--- + +# Migrate from Bluehost + +Bluehost runs cPanel on its shared hosting plans, which makes migrating to Arcline straightforward — both platforms speak the same language. The process is: export from Bluehost, set up on Arcline, test, then switch nameservers. + +--- + +## Before you start + +- Bluehost cPanel access (log in at your Bluehost dashboard → **Advanced** to reach cPanel) +- Arcline cPanel account +- 30–90 minutes depending on database and file size +- Note your Bluehost PHP version: cPanel → **Software → Select PHP Version** + +--- + +## Step 1 — Back up files from Bluehost + +### Full cPanel backup + +1. Bluehost cPanel → **Files → Backup** (or search for "Backup Wizard") +2. Choose **Full Backup** and download to your local machine + +This gives you an archive with `public_html` and all databases. + +### Manual file backup via SFTP + +If you only want specific files, connect to Bluehost via SFTP: + +- Host: `ftp.yourdomain.com` or your server hostname +- Port: `21` (FTP) or `22` (SFTP) — use SFTP if available +- Username/password: your cPanel credentials + +Download `/home/username/public_html/` to your local machine. + +--- + +## Step 2 — Export your database from Bluehost + +1. Bluehost cPanel → **Databases → phpMyAdmin** +2. Click your database name in the left sidebar +3. Click **Export → Quick → SQL → Go** + +For large databases, use **Custom** export and enable **Add DROP TABLE**. You can also export via SSH: + +```bash +mysqldump -u cpanelusername_dbuser -p cpanelusername_dbname > bluehost_backup.sql +``` + +--- + +## Step 3 — Set up on Arcline + +**Create the database:** +1. Arcline cPanel → **Databases → MySQL Databases** +2. Create a database and a database user +3. Assign the user to the database with **All Privileges** + +**Import the database:** +1. cPanel → phpMyAdmin → select your new database → **Import** +2. Upload the `.sql` file from Bluehost +3. Click **Go** + +For files over 50 MB, use the command line (see [Back Up and Restore a MySQL Database](/getting-started/mysql-backup/)). + +**Upload your files:** +Connect to Arcline via SFTP and upload your site to `/home/username/public_html/`. See [Upload Files via SFTP](/getting-started/sftp/). + +--- + +## Step 4 — Update wp-config.php + +WordPress stores database credentials in `wp-config.php`. Update the values to match the new database you created: + +```php +define( 'DB_NAME', 'newcpanel_dbname' ); +define( 'DB_USER', 'newcpanel_dbuser' ); +define( 'DB_PASSWORD', 'your-password' ); +define( 'DB_HOST', 'localhost' ); +``` + +Bluehost sometimes uses a custom `DB_HOST` value. On Arcline it is always `localhost`. + +--- + +## Step 5 — Match the PHP version + +Bluehost's default PHP version may differ from Arcline's. To prevent compatibility errors: + +1. Check the PHP version in Bluehost cPanel → **Software → Select PHP Version** +2. Set the same version in Arcline cPanel → **Software → Select PHP Version** +3. Also check that any PHP extensions your site requires are enabled on Arcline (the **Extensions** tab in MultiPHP Manager) + +WordPress 6.x requires PHP 7.4 minimum; PHP 8.1 or 8.2 is recommended. + +--- + +## Step 6 — Test the site before switching DNS + +Edit your local `hosts` file to preview the Arcline version without changing DNS. + +Find your Arcline server IP in cPanel → **Server Information**. + +Add this line to your hosts file: + +``` +YOUR_ARCLINE_IP yourdomain.com www.yourdomain.com +``` + +**macOS / Linux:** `/etc/hosts` (requires sudo) +**Windows:** `C:\Windows\System32\drivers\etc\hosts` (open as Administrator) + +Visit your domain in a browser and verify: +- Pages and images load +- WordPress admin works +- Forms and any dynamic features work +- SSL is active (AutoSSL issues a cert after DNS resolves, which it will once nameservers switch — for pre-launch testing self-signed is normal) + +Remove the hosts entry once done. + +--- + +## Step 7 — Switch nameservers + +When the site checks out, update nameservers to Arcline. Bluehost is often both the host and the registrar. + +**If your domain is registered at Bluehost:** + +1. Log in to Bluehost → **Domains → My Domains** +2. Click **Manage** → **Nameservers** → select **Custom Nameservers** +3. Enter: + - `ns1.arcline.it` + - `ns2.arcline.it` +4. Save + +**If your domain is registered elsewhere**, update nameservers at that registrar instead. See [Point Your Domain to Arcline](/getting-started/nameservers/). + +Keep Bluehost live for 24–48 hours while DNS propagates. Don't cancel your Bluehost account until propagation is confirmed and you've verified email is working. + +--- + +## Step 8 — Email + +**Moving to Arcline email:** + +1. Create accounts in Arcline cPanel → **Email → Email Accounts** +2. Once nameservers switch, Arcline's MX records activate automatically +3. Export any mail you want to keep from Bluehost webmail (Bluehost uses Roundcube or Horde) before cancelling + +**Keeping external email (Google Workspace, Microsoft 365):** + +After switching nameservers to Arcline, add your existing MX records back in cPanel → **Domains → Zone Editor**. You'll find the required MX records in your Google or Microsoft 365 admin panel. + +--- + +## Bluehost-specific notes + +**SiteLock or CodeGuard backups** — these are Bluehost add-ons. You don't need them on Arcline; standard cPanel backups are included. + +**WordPress staging site** — Bluehost provides a staging environment. After migration, delete the staging version from Bluehost to avoid confusion during the transition. + +**Bluehost's custom wp-config constants** — Bluehost adds some non-standard constants to `wp-config.php` (like `COOKIE_DOMAIN` or Bluehost-specific feature flags). These can be removed safely after migrating. + +**"Account Suspension" page appearing** — if Bluehost's CDN or caching layer still serves your domain during propagation, visitors may briefly see a Bluehost holding page. This resolves once propagation is complete. + +**File permissions** — Bluehost uses suPHP/suEXEC. Standard permissions (644 files, 755 directories) work on Arcline. If Bluehost set unusual permissions (e.g., 777), correct them on Arcline. + +--- + +## After migration checklist + +- [ ] Site loads at `https://yourdomain.com` +- [ ] WordPress admin accessible at `/wp-admin` +- [ ] Forms submit and send email +- [ ] SSL certificate shows valid in browser +- [ ] Email accounts created and working +- [ ] Old Bluehost account not yet cancelled (wait 48 hours) +- [ ] DNS fully propagated (check at dnschecker.org) diff --git a/content/migrate/from-godaddy.md b/content/migrate/from-godaddy.md new file mode 100644 index 0000000..f8909e3 --- /dev/null +++ b/content/migrate/from-godaddy.md @@ -0,0 +1,176 @@ +--- +title: "Migrate from GoDaddy" +description: "Step-by-step guide to moving your website and email from GoDaddy shared hosting to Arcline without downtime." +section: migrate +order: 1 +--- + +# Migrate from GoDaddy + +Moving from GoDaddy to Arcline involves three parts: transferring your files, moving your database, and finally switching DNS. Done in that order, you can complete the whole migration with zero downtime. + +--- + +## Before you start + +You'll need: +- GoDaddy cPanel login (or FTP credentials if you're on GoDaddy's non-cPanel shared hosting) +- Access to your Arcline cPanel account +- Your domain's registrar login (wherever you registered the domain — could be GoDaddy itself) +- About 30–60 minutes depending on site size + +--- + +## Step 1 — Export your files from GoDaddy + +### If GoDaddy uses cPanel + +Log into GoDaddy's cPanel and create a full backup: + +1. cPanel → **Files → Backup** (or **Backup Wizard**) +2. Choose **Full Account Backup** and download to your computer + +The backup includes your `public_html` files and all databases. + +### If GoDaddy uses their custom control panel (Websites + Marketing / Managed WordPress) + +GoDaddy's Managed WordPress product doesn't give you direct file or database access. Options: + +- Use the **Jetpack** or **UpdraftPlus** plugin to export a full backup (files + database) that you can import on Arcline +- Use the WordPress **Export** tool (Tools → Export) for content only, then reinstall plugins/themes manually +- Contact GoDaddy support and request a backup — they're required by some hosting terms to provide one on request + +For standard GoDaddy shared hosting (cPanel-based), direct export is straightforward. + +--- + +## Step 2 — Export your database from GoDaddy + +If you used the full cPanel backup in Step 1, the database is already included. Otherwise: + +1. GoDaddy cPanel → **Databases → phpMyAdmin** +2. Click your database name in the left sidebar +3. Click **Export → Quick → SQL → Go** + +Save the `.sql` file. + +For large databases: use **Custom** export, enable **Add DROP TABLE**, and export table-by-table if phpMyAdmin times out. + +--- + +## Step 3 — Set up your site on Arcline + +**Create the database:** + +1. Arcline cPanel → **Databases → MySQL Databases** +2. Create a new database and a new user +3. Add the user to the database with **All Privileges** +4. Note the database name, username, and password + +**Import the database:** + +1. cPanel → phpMyAdmin → select the new database → **Import** +2. Upload the `.sql` file from GoDaddy +3. Click **Go** + +For large databases, import via SSH instead (see [Back Up and Restore a MySQL Database](/getting-started/mysql-backup/)). + +**Upload your files:** + +Connect to your Arcline server via SFTP (see [Upload Files via SFTP](/getting-started/sftp/)) and upload your site files to `/home/username/public_html/`. + +If you downloaded a full cPanel backup, extract it first and locate the `public_html` folder inside. + +--- + +## Step 4 — Update your database connection settings + +WordPress stores the database host, name, username, and password in `wp-config.php`. Update it to point to the new database you created in Step 3: + +```php +define( 'DB_NAME', 'cpanelusername_newdbname' ); +define( 'DB_USER', 'cpanelusername_newdbuser' ); +define( 'DB_PASSWORD', 'your-new-password' ); +define( 'DB_HOST', 'localhost' ); +``` + +On GoDaddy, `DB_HOST` is sometimes a custom hostname. On Arcline it's always `localhost`. + +For non-WordPress sites, update whatever config file stores your database credentials (`.env`, `config.php`, `database.yml`, etc.). + +--- + +## Step 5 — Test before going live + +Before switching DNS, preview your site on Arcline by editing your local `hosts` file. + +Find your Arcline server IP in cPanel → **Server Information**. + +**On macOS / Linux**, edit `/etc/hosts` (requires sudo): + +``` +123.456.789.0 yourdomain.com www.yourdomain.com +``` + +**On Windows**, edit `C:\Windows\System32\drivers\etc\hosts` as Administrator with the same line. + +Now visit `https://yourdomain.com` in your browser — you'll see the Arcline version of your site even though DNS still points to GoDaddy. Check: +- Pages load correctly +- Images appear +- Forms work +- Login works (WordPress admin, etc.) +- SSL certificate is valid (if AutoSSL has run) + +Remove the `hosts` entry when done testing. + +--- + +## Step 6 — Switch DNS + +When you're happy the site works on Arcline: + +**If your domain is registered at GoDaddy:** + +1. GoDaddy → My Products → Domains → click your domain +2. Scroll to **Nameservers → Change → I'll use my own nameservers** +3. Enter `ns1.arcline.it` and `ns2.arcline.it` +4. Save + +**If your domain is registered elsewhere:** + +Follow the nameserver update instructions for your registrar. See [Point Your Domain to Arcline](/getting-started/nameservers/) for provider-specific steps. + +DNS propagation takes 15 minutes to a few hours. During this time, some visitors may still hit GoDaddy. Keep the GoDaddy site live until propagation is complete (at least 24 hours). + +--- + +## Step 7 — Email migration + +If you were using GoDaddy email (Workspace Email or Microsoft 365 through GoDaddy), you'll need to decide what to do with email: + +**Switching to Arcline email:** +1. Create email accounts in Arcline cPanel → **Email → Email Accounts** +2. After nameservers switch, Arcline handles MX automatically +3. Export any email you want to keep from GoDaddy webmail before the cutover (GoDaddy Workspace Email → Settings → Export) + +**Keeping GoDaddy email (or Microsoft 365):** +After switching nameservers to Arcline, add the original MX records back in cPanel → **Domains → Zone Editor**. GoDaddy's Workspace Email uses: + +| Type | Priority | Value | +|---|---|---| +| MX | 0 | `mailstore1.secureserver.net` | +| MX | 10 | `smtp.secureserver.net` | + +For Microsoft 365 through GoDaddy, the MX record is specific to your tenant — find it in your Microsoft 365 admin portal. + +--- + +## Common GoDaddy-specific issues + +**Site was on a subdomain (e.g., GoDaddy's temp URL)** — GoDaddy gives sites a staging URL while DNS isn't pointed. Make sure `siteurl` and `home` in WordPress → Settings → General (or `wp-config.php`) are set to your real domain, not GoDaddy's temp URL. + +**GoDaddy's PHP version differs** — GoDaddy defaults vary. Check your PHP version in GoDaddy cPanel and match it in Arcline cPanel → **Software → Select PHP Version** to avoid compatibility issues. + +**GoDaddy file permissions** — GoDaddy sometimes uses suEXEC with specific permission requirements. On Arcline, standard permissions (644 for files, 755 for directories) are correct. + +**Email forwarding was set up at GoDaddy** — if you had forwarders, recreate them in Arcline cPanel → **Email → Forwarders** after switching nameservers. diff --git a/content/migrate/from-hostgator.md b/content/migrate/from-hostgator.md new file mode 100644 index 0000000..bf7a7e3 --- /dev/null +++ b/content/migrate/from-hostgator.md @@ -0,0 +1,195 @@ +--- +title: "Migrate from HostGator" +description: "How to move your website from HostGator shared hosting to Arcline, including WordPress and cPanel-to-cPanel transfers." +section: migrate +order: 5 +--- + +# Migrate from HostGator + +HostGator runs cPanel on its shared and reseller plans, so migrating to Arcline is familiar territory. You'll export your files and database from HostGator, set them up on Arcline, test, and then switch nameservers. + +--- + +## Before you start + +- HostGator customer portal login (at [portal.hostgator.com](https://portal.hostgator.com)) — you may need this to reach cPanel if single sign-on is enabled +- Arcline cPanel account +- 30–90 minutes depending on site size +- Note your HostGator PHP version: cPanel → **Software → MultiPHP Manager** + +--- + +## Step 1 — Export your files from HostGator + +### From cPanel + +1. Log in to HostGator cPanel (via customer portal or directly at `https://yourdomain.com:2083`) +2. **Files → Backup** (or **Backup Wizard**) +3. Choose **Full Backup** and download to your local machine + +This produces a single archive with your `public_html` directory and all databases. + +### Via SFTP + +If the cPanel backup is too large to download through the browser, use SFTP: + +- Host: your server hostname or IP (find it in HostGator cPanel → **Server Information**) +- Port: `2222` (HostGator's default non-standard SSH/SFTP port) +- Username and password: your cPanel credentials + +Download everything in `/home/username/public_html/` to your local machine. + +--- + +## Step 2 — Export your database from HostGator + +1. HostGator cPanel → **Databases → phpMyAdmin** +2. Click your database name in the left sidebar +3. **Export → Quick → SQL → Go** + +Save the `.sql` file. + +For databases over 50 MB, use **Custom** export and check **Add DROP TABLE**. If phpMyAdmin times out, export via SSH: + +```bash +mysqldump -u cpaneluser_dbuser -p cpaneluser_dbname > hostgator_backup.sql +``` + +--- + +## Step 3 — Set up on Arcline + +**Create the database:** + +1. Arcline cPanel → **Databases → MySQL Databases** +2. Create a new database and a new user +3. Add the user to the database with **All Privileges** +4. Write down the database name, username, and password + +**Import the database:** + +1. cPanel → phpMyAdmin → select the new (empty) database → **Import** +2. Choose the `.sql` file from HostGator +3. Click **Go** + +For large imports, use the command line (see [Back Up and Restore a MySQL Database](/getting-started/mysql-backup/)). + +**Upload your files:** + +Connect to Arcline via SFTP and upload everything to `/home/username/public_html/`. See [Upload Files via SFTP](/getting-started/sftp/). + +--- + +## Step 4 — Update wp-config.php + +Edit your `wp-config.php` to use the new Arcline database credentials: + +```php +define( 'DB_NAME', 'arclineuser_newdbname' ); +define( 'DB_USER', 'arclineuser_newdbuser' ); +define( 'DB_PASSWORD', 'your-new-password' ); +define( 'DB_HOST', 'localhost' ); +``` + +HostGator typically uses `localhost` for `DB_HOST` — same as Arcline. Update the database name, username, and password to the values you created in Step 3. + +For non-WordPress sites, update the equivalent database configuration file. + +--- + +## Step 5 — Check PHP version and extensions + +1. HostGator cPanel → **Software → MultiPHP Manager** or **Select PHP Version** — note the version +2. Arcline cPanel → **Software → Select PHP Version** — set the same version +3. Check the **Extensions** tab to match any custom extensions your site uses + +HostGator supports PHP 5.6 through 8.x. If your site is running an older version (5.6 or 7.0–7.3), consider updating to PHP 8.1+ on Arcline — test thoroughly before switching DNS. + +--- + +## Step 6 — Test before DNS cutover + +Add Arcline's server IP to your local `hosts` file to preview the site before changing DNS. + +Find your Arcline IP in cPanel → **Server Information**. + +Add this line to your hosts file: + +``` +YOUR_ARCLINE_IP yourdomain.com www.yourdomain.com +``` + +- **macOS / Linux:** `/etc/hosts` (requires sudo) +- **Windows:** `C:\Windows\System32\drivers\etc\hosts` (open as Administrator) + +Visit `https://yourdomain.com` and verify: + +- The homepage and key pages load +- Images and media work +- WordPress admin is accessible +- Contact forms, shopping cart, and other dynamic features work correctly + +Remove the `hosts` entry after testing. + +--- + +## Step 7 — Switch nameservers + +**If your domain is registered at HostGator:** + +1. Log in to HostGator customer portal → **Domains** +2. Select your domain and go to **Nameservers** +3. Choose **Custom Nameservers** and enter: + - `ns1.arcline.it` + - `ns2.arcline.it` +4. Save changes + +**If your domain is registered elsewhere**, update nameservers at the registrar. See [Point Your Domain to Arcline](/getting-started/nameservers/) for provider-specific steps. + +DNS propagation takes 15 minutes to a few hours. Keep HostGator active for at least 24 hours during propagation. + +--- + +## Step 8 — Email migration + +**Moving to Arcline email:** + +1. Create your email accounts in Arcline cPanel → **Email → Email Accounts** +2. Once nameservers switch, Arcline handles MX records automatically +3. Export any mail you want to keep from HostGator webmail (Roundcube or Horde) before cancelling + +**Keeping external email (Google Workspace, Microsoft 365):** + +If you use an external email provider and only used HostGator for hosting, add your existing MX records back in Arcline cPanel → **Domains → Zone Editor** after switching nameservers. + +--- + +## HostGator-specific notes + +**SiteLock** — HostGator pushes SiteLock as an add-on for malware scanning. You don't need it on Arcline; standard WordPress security practices (keep everything updated, use strong passwords, limit login attempts) are sufficient. If you want extra scanning, use a free alternative like the Wordfence plugin. + +**HostGator's custom caching** — HostGator sometimes uses server-level caching (Varnish or similar). Your site won't have this on Arcline, but standard WordPress caching plugins (W3 Total Cache, WP Super Cache) work well and give you more control. + +**QuickInstall / Softaculous apps** — if you installed WordPress or other apps through HostGator's auto-installer, the installation itself is standard — it will migrate normally. The auto-installer is just a convenience tool; your site doesn't depend on it. + +**HostGator's `public_html` / `www` difference** — HostGator sometimes uses `www` as a symlink to `public_html`. On Arcline, your site files go directly in `public_html`. Just upload everything there. + +**Resource usage warnings** — HostGator's shared plans have strict inode and CPU limits. If you received warnings about resource usage, these don't apply on Arcline — our shared plans have different limits. Check your site's resource usage on Arcline's cPanel (left sidebar → **Resource Usage**). + +**HostGator website builder** — if you built your site with HostGator's proprietary website builder (Gator Website Builder), it can't be exported as files. You'll need to rebuild the site on Arcline, or use a WordPress-based site instead. This guide only covers cPanel-based sites with standard file/database exports. + +--- + +## After migration checklist + +- [ ] Site loads correctly at `https://yourdomain.com` +- [ ] WordPress admin accessible at `/wp-admin` +- [ ] Forms submit and send email +- [ ] SSL certificate valid (AutoSSL in cPanel → **SSL/TLS Status**) +- [ ] PHP version matches what the site expects +- [ ] Email accounts created and working +- [ ] DNS propagated (check at dnschecker.org) +- [ ] HostGator account kept active for 48 hours as fallback +- [ ] HostGator account cancelled only after confirming everything works + diff --git a/content/migrate/from-namecheap.md b/content/migrate/from-namecheap.md new file mode 100644 index 0000000..5aba700 --- /dev/null +++ b/content/migrate/from-namecheap.md @@ -0,0 +1,201 @@ +--- +title: "Migrate from Namecheap" +description: "Move your website and email from Namecheap shared hosting to Arcline — step by step, with zero downtime." +section: migrate +order: 4 +--- + +# Migrate from Namecheap + +Namecheap's shared hosting runs cPanel, which makes moving to Arcline straightforward. The process covers exporting your files and databases from Namecheap, setting them up on Arcline, testing, and then switching DNS. + +--- + +## Before you start + +- Namecheap cPanel login (reachable from your Namecheap dashboard → Hosting List → Manage → cPanel) +- Arcline cPanel account +- 30–90 minutes depending on site size +- Note your Namecheap PHP version: cPanel → **Software → Select PHP Version** + +Namecheap is often both the host **and** the registrar — if your domain is registered there, you'll update nameservers in Namecheap's domain panel (covered in Step 6). + +--- + +## Step 1 — Export your files from Namecheap + +### Full cPanel backup + +1. Namecheap cPanel → **Files → Backup** (or **Backup Wizard**) +2. Choose **Full Backup** and download to your local machine + +This includes `public_html` and all databases in one archive. + +### Manual export via SFTP + +If you prefer to download files individually: + +- Host: your server hostname or IP (find it in Namecheap cPanel → **Server Information**) +- Port: `21098` (Namecheap's default SFTP port) or `21` (FTP) +- Username and password: your cPanel credentials + +Download `/home/username/public_html/` to your local machine. + +--- + +## Step 2 — Export your database from Namecheap + +1. Namecheap cPanel → **Databases → phpMyAdmin** +2. Click your database name in the left sidebar +3. Click **Export → Quick → SQL → Go** + +Save the `.sql` file. + +For large databases (over 50 MB), use **Custom** export and enable **Add DROP TABLE**, or export via SSH: + +```bash +mysqldump -u cpaneluser_dbuser -p cpaneluser_dbname > namecheap_backup.sql +``` + +--- + +## Step 3 — Set up on Arcline + +**Create the database:** + +1. Arcline cPanel → **Databases → MySQL Databases** +2. Create a new database and a new user +3. Add the user to the database with **All Privileges** +4. Note the database name, username, and password — you'll need them for `wp-config.php` + +**Import the database:** + +1. cPanel → phpMyAdmin → select the new database → **Import** +2. Upload the `.sql` file from Namecheap +3. Click **Go** + +For files over 50 MB, import via the command line instead (see [Back Up and Restore a MySQL Database](/getting-started/mysql-backup/)). + +**Upload your files:** + +Connect to Arcline via SFTP (see [Upload Files via SFTP](/getting-started/sftp/)) and upload your site to `/home/username/public_html/`. + +--- + +## Step 4 — Update your database connection settings + +WordPress stores database credentials in `wp-config.php`. Edit it to point to the new Arcline database: + +```php +define( 'DB_NAME', 'arclineuser_newdbname' ); +define( 'DB_USER', 'arclineuser_newdbuser' ); +define( 'DB_PASSWORD', 'your-new-password' ); +define( 'DB_HOST', 'localhost' ); +``` + +On Namecheap, `DB_HOST` is typically `localhost` — same as Arcline. Change the other three values to match your new Arcline database. + +For non-WordPress sites, update whatever config file stores your database credentials (`.env`, `config.php`, `database.yml`, etc.). + +--- + +## Step 5 — Match the PHP version + +Namecheap's default PHP version may differ from Arcline's. Check and match: + +1. Namecheap cPanel → **Software → Select PHP Version** — note the version +2. Arcline cPanel → **Software → Select PHP Version** — set the same version +3. Also check the **Extensions** tab to enable any extensions your site needs (common ones: `mbstring`, `curl`, `gd`, `zip`, `intl`) + +--- + +## Step 6 — Test before going live + +Before switching DNS, preview your site on Arcline using your local `hosts` file. + +Find your Arcline server IP in cPanel → **Server Information**. + +**On macOS / Linux**, edit `/etc/hosts` (requires sudo): + +``` +YOUR_ARCLINE_IP yourdomain.com www.yourdomain.com +``` + +**On Windows**, edit `C:\Windows\System32\drivers\etc\hosts` as Administrator with the same line. + +Now visit `https://yourdomain.com` in your browser. Check: + +- Pages load correctly +- Images appear +- WordPress admin (/wp-admin) works +- Contact forms submit +- Any dynamic features (shopping cart, search) work + +Remove the `hosts` entry when done testing. + +--- + +## Step 7 — Switch nameservers + +Since Namecheap is likely both your host and registrar, you'll update nameservers in the Namecheap domain panel (not cPanel). + +1. Log in to Namecheap → **Domain List** +2. Click **Manage** next to your domain +3. Under **Nameservers**, change the dropdown from **Namecheap BasicDNS** to **Custom DNS** +4. Enter: + - `ns1.arcline.it` + - `ns2.arcline.it` +5. Click the green checkmark to save + +DNS propagation takes 15 minutes to a few hours. Keep Namecheap's hosting active during propagation — both servers will serve the site until DNS fully switches. + +> If your domain is registered **elsewhere**, update nameservers at that registrar. See [Point Your Domain to Arcline](/getting-started/nameservers/) for steps by provider. + +--- + +## Step 8 — Email migration + +**Moving to Arcline email:** + +1. Create email accounts in Arcline cPanel → **Email → Email Accounts** +2. After nameservers switch, Arcline's MX records activate automatically +3. Export any mail you want to keep from Namecheap webmail (usually Roundcube) before cancelling + +**Keeping Namecheap email (Private Email):** + +If you subscribed to Namecheap's Private Email service and want to keep it, add the MX records back in Arcline cPanel → **Domains → Zone Editor** after switching nameservers. Namecheap Private Email uses: + +| Type | Priority | Value | +|-------|----------|-------------------------------| +| MX | 10 | `mx1.privateemail.com` | +| MX | 20 | `mx2.privateemail.com` | + +You'll also need the SPF record Namecheap provides in your Private Email settings. + +--- + +## Namecheap-specific notes + +**Namecheap's SSL certificates** — Namecheap sells PositiveSSL certificates. These don't transfer to Arcline. Arcline includes free AutoSSL (Let's Encrypt) which will issue automatically once your domain resolves to Arcline. Don't bother exporting Namecheap's SSL cert. + +**Supersonic CDN** — Namecheap includes a basic CDN on some plans. Your site doesn't need it on Arcline; our infrastructure already includes caching and performance optimization by default. + +**Namecheap's backup service** — Namecheap offers paid automated backups. You don't need this add-on on Arcline — use cPanel's built-in backup tools or set up a cron-based backup (see [Back Up and Restore a MySQL Database](/getting-started/mysql-backup/)). + +**EasyWP (Namecheap managed WordPress)** — if you're on Namecheap's EasyWP platform instead of cPanel hosting, you can't export via cPanel. Use a WordPress backup plugin (UpdraftPlus or All-in-One WP Migration) to export your files and database, then import the backup on Arcline. + +**.htaccess redirects** — if Namecheap added custom redirects in your `.htaccess` file (e.g., non-www to www), they'll come over with your files and work on Arcline without changes. + +--- + +## After migration checklist + +- [ ] Site loads at `https://yourdomain.com` +- [ ] WordPress admin accessible at `/wp-admin` +- [ ] Forms submit and send email +- [ ] SSL certificate valid (check cPanel → **SSL/TLS Status**) +- [ ] Email accounts created and working +- [ ] PHP version and extensions match +- [ ] DNS fully propagated (verify at dnschecker.org) +- [ ] Namecheap hosting account kept active for 48 hours as fallback + diff --git a/content/migrate/from-siteground.md b/content/migrate/from-siteground.md new file mode 100644 index 0000000..7215637 --- /dev/null +++ b/content/migrate/from-siteground.md @@ -0,0 +1,200 @@ +--- +title: "Migrate from SiteGround" +description: "How to transfer your website and database from SiteGround to Arcline, including handling SiteGround's custom tools and SuperCacher." +section: migrate +order: 3 +--- + +# Migrate from SiteGround + +SiteGround uses cPanel on older accounts and their own Site Tools panel on newer ones. The migration process is similar either way: export your files and database, set up on Arcline, test, then cut over DNS. + +--- + +## Before you start + +- SiteGround Site Tools or cPanel access +- Arcline cPanel account +- 30–90 minutes depending on site size +- Note any SiteGround-specific plugins installed (see [SiteGround-specific notes](#siteground-specific-notes)) + +--- + +## Step 1 — Export files from SiteGround + +### Via Site Tools (newer SiteGround accounts) + +SiteGround's Site Tools replaced cPanel on newer accounts. To export your files: + +1. Site Tools → **Site → FTP Accounts** — create an FTP user if you don't have one +2. Connect with FileZilla or Cyberduck to `yourdomain.com` on port 22 (SFTP) or 21 (FTP) +3. Download your `public_html` folder + +Or use SiteGround's Backup tool: + +1. Site Tools → **Security → Backups** +2. Select the most recent backup date +3. Click **Restore** on **File System** — but choose **Download** instead of restore to download the archive + +### Via cPanel (older SiteGround accounts) + +cPanel → **Files → Backup Wizard → Full Backup → Download** + +This creates a full archive including databases. + +--- + +## Step 2 — Export your database + +### Site Tools + +1. Site Tools → **Site → MySQL** → select your database → click **phpMyAdmin** +2. Click the database name in the left sidebar +3. **Export → Quick → SQL → Go** + +Or use the **Backup** tool → select the most recent backup → **Download** on the database section. + +### cPanel + +cPanel → **Databases → phpMyAdmin** → select database → **Export → Quick → SQL → Go** + +For large databases, export via SSH: + +```bash +mysqldump -u dbusername -p dbname > siteground_backup.sql +``` + +--- + +## Step 3 — Set up on Arcline + +**Create the database:** +1. Arcline cPanel → **Databases → MySQL Databases** +2. Create a database, a user, and assign the user with **All Privileges** +3. Note all three: database name, username, password + +**Import the database:** +1. cPanel → phpMyAdmin → select the new (empty) database → **Import** +2. Choose the `.sql` file from SiteGround +3. Click **Go** + +For large files (over 50 MB), import via SSH (see [Back Up and Restore a MySQL Database](/getting-started/mysql-backup/)). + +**Upload files:** +Connect via SFTP to Arcline and upload everything to `/home/username/public_html/`. See [Upload Files via SFTP](/getting-started/sftp/). + +--- + +## Step 4 — Update database credentials + +Edit `wp-config.php` (or your site's database config file) with the new credentials: + +```php +define( 'DB_NAME', 'arclinecpanel_dbname' ); +define( 'DB_USER', 'arclinecpanel_dbuser' ); +define( 'DB_PASSWORD', 'your-new-password' ); +define( 'DB_HOST', 'localhost' ); +``` + +SiteGround sometimes uses a non-localhost `DB_HOST` (e.g., a private IP). On Arcline, it's always `localhost`. + +--- + +## Step 5 — Check PHP version + +1. SiteGround Site Tools → **Devs → PHP Manager** — note the PHP version +2. Arcline cPanel → **Software → Select PHP Version** — match it + +Also enable any PHP extensions your site needs. Common ones: `mbstring`, `curl`, `gd`, `imagick`, `intl`, `zip`. Check the **Extensions** tab in MultiPHP Manager on Arcline. + +--- + +## Step 6 — Remove SiteGround caching + +SiteGround's SuperCacher (now called SG Optimizer) installs a caching plugin and server-side caching. Before testing on Arcline: + +1. Deactivate the **SG Optimizer** plugin (or **SiteGround Optimizer**) — it will try to connect to SiteGround's infrastructure which won't be available on Arcline +2. Delete any `.htaccess` lines added by SG Optimizer that reference SiteGround-specific caching rules +3. Install a standard WordPress caching plugin instead (LiteSpeed Cache, W3 Total Cache, or WP Super Cache work well on Arcline) + +If you leave SG Optimizer active, the plugin will disable itself gracefully on non-SiteGround servers, but it's cleaner to remove it. + +--- + +## Step 7 — Test before DNS cutover + +Add your Arcline server IP to your local `hosts` file: + +``` +YOUR_ARCLINE_IP yourdomain.com www.yourdomain.com +``` + +**macOS / Linux:** `/etc/hosts` (requires sudo) +**Windows:** `C:\Windows\System32\drivers\etc\hosts` (as Administrator) + +Visit your domain and check: +- Homepage and key pages load +- Images display +- WordPress admin works +- Contact forms submit +- Any e-commerce checkout works (if applicable) + +Remove the `hosts` entry when done. + +--- + +## Step 8 — Switch nameservers + +**If your domain is registered at SiteGround** (they became a domain registrar): + +1. SiteGround → **Domains** → select your domain +2. Go to **Nameservers** and select **Custom Nameservers** +3. Enter: + - `ns1.arcline.it` + - `ns2.arcline.it` +4. Save + +**If registered elsewhere**, update nameservers at that registrar. See [Point Your Domain to Arcline](/getting-started/nameservers/). + +Leave SiteGround running for 24–48 hours. Both servers will serve the site during propagation — that's fine, the content is the same. + +--- + +## Step 9 — Email migration + +**Moving to Arcline email:** +1. Create accounts in Arcline cPanel → **Email → Email Accounts** +2. Arcline's MX records activate after nameservers switch +3. Export existing email from SiteGround's webmail (Roundcube) before cancelling + +**Keeping Google Workspace or another provider:** +After switching nameservers, re-add your external MX records in Arcline cPanel → **Domains → Zone Editor**. + +--- + +## SiteGround-specific notes + +**SG Optimizer / SuperCacher** — disable and remove before or shortly after migration. It won't cause harm on Arcline but serves no purpose. + +**SiteGround staging** — if you used SiteGround's staging feature, the staging site lives on their servers. You can ignore it; migrate only the production site. + +**Cloudflare via SiteGround** — SiteGround has a built-in Cloudflare integration. If your site was using it, you have a Cloudflare account attached to SiteGround. After migrating, you can either: +- Switch to Arcline nameservers directly (disables the SiteGround-managed Cloudflare) +- Keep Cloudflare: transfer the zone to your own Cloudflare account and update the A record to point to Arcline's IP + +**SiteGround emails about resource limits** — if you were on SiteGround's entry-level plan, you may have hit CPU/memory limits. These limits don't carry over; Arcline has its own resource allocation. + +**WordPress auto-updates** — SiteGround has WordPress auto-update settings in Site Tools. These won't apply on Arcline. Set up your own update preferences in WordPress → **Dashboard → Updates** or use a plugin like ManageWP. + +--- + +## After migration checklist + +- [ ] Site loads at `https://yourdomain.com` +- [ ] SSL certificate valid +- [ ] WordPress admin accessible +- [ ] SG Optimizer deactivated/removed +- [ ] Caching plugin configured for Arcline +- [ ] Email accounts working +- [ ] DNS propagated (verify at dnschecker.org) +- [ ] SiteGround account kept active for 48 hours as fallback diff --git a/content/migrate/from-wpengine.md b/content/migrate/from-wpengine.md new file mode 100644 index 0000000..be41328 --- /dev/null +++ b/content/migrate/from-wpengine.md @@ -0,0 +1,274 @@ +--- +title: "Migrate from WP Engine or Kinsta" +description: "How to move a WordPress site from managed hosting (WP Engine, Kinsta) to Arcline's VPS or shared hosting without downtime." +section: migrate +order: 6 +--- + +# Migrate from WP Engine or Kinsta + +WP Engine and Kinsta are managed WordPress hosts. They don't provide cPanel or direct server access — instead you get a custom dashboard with WordPress-specific tools. Migrating to Arcline involves exporting your site through WordPress itself and setting it up fresh. + +The process is similar for both WP Engine and Kinsta. Differences are called out where they matter. + +--- + +## Before you start + +- WP Engine User Portal or Kinsta MyKinsta dashboard access +- WordPress admin access (for plugin-based backups) +- Arcline cPanel account (for shared hosting) or VPS access +- 45–90 minutes depending on your site size +- **Important:** Managed hosts sometimes disallow certain plugins. Check the [WP Engine disallowed plugins list](https://wpengine.com/support/disallowed-plugins/) or [Kinsta banned plugins list](https://kinsta.com/knowledgebase/banned-plugins/) — if your backup plugin is on the list, use the manual export method instead. + +--- + +## Step 1 — Export your WordPress site + +You have three options for exporting. Choose one. + +### Option A: UpdraftPlus (easiest) + +1. Install the **UpdraftPlus** plugin from your WordPress admin → Plugins → Add New +2. Go to **Settings → UpdraftPlus Backups** +3. Click **Backup Now** and select both **Database** and **Files** +4. When the backup completes, download all five files from the **Existing Backups** tab: + - Database + - Plugins + - Themes + - Uploads + - Others + +### Option B: All-in-One WP Migration + +1. Install **All-in-One WP Migration** from Plugins → Add New +2. Go to **All-in-One WP Migration → Export** +3. Choose **Export To → File** +4. Download the `.wpm` file when it's ready + +> The free version has a 512 MB upload limit for importing. If your site is larger, use UpdraftPlus or manual export instead. + +### Option C: Manual export + +**Export files from WP Engine:** + +WP Engine provides SFTP access — you can connect with FileZilla or Cyberduck: + +1. WP Engine User Portal → **Sites → your site → SFTP** +2. Note the host, port (2222), username, and password shown +3. Connect with FileZilla and download the entire `wp-content` folder + +**Export files from Kinsta:** + +Kinsta provides SFTP and SSH access: + +1. MyKinsta → **Sites → your site → Info** +2. Under **SFTP/SSH**, note the host, port, username, and password +3. Connect with FileZilla and download the `wp-content` folder + +**Export the database:** + +Both hosts include phpMyAdmin access: + +1. **WP Engine:** User Portal → Sites → your site → **phpMyAdmin** (under Utilities) +2. **Kinsta:** MyKinsta → Sites → your site → **Info** → Open phpMyAdmin + +Select your database in the left sidebar → **Export → Quick → SQL → Go**. + +--- + +## Step 2 — Set up WordPress on Arcline + +### On shared hosting (cPanel) + +1. Arcline cPanel → **Software → WordPress Manager by Softaculous**, or install manually +2. Create a fresh WordPress installation in the root `public_html` directory (or a subdirectory if you prefer to test there first) + +### Manual WordPress install + +1. Download WordPress from [wordpress.org](https://wordpress.org/download/) +2. Upload the files to `/home/username/public_html/` via SFTP +3. Create a database in cPanel → **Databases → MySQL Databases** +4. Create a database user and add it to the database with **All Privileges** +5. Visit `https://yourdomain.com` and complete the WordPress installation wizard +6. Use the database name, username, and password from step 3 + +### On a VPS + +Follow the VPS setup guides for installing WordPress on a LAMP or LEMP stack. + +--- + +## Step 3 — Import your site + +### If you used UpdraftPlus + +1. Install and activate UpdraftPlus on your fresh Arcline WordPress installation +2. Go to **Settings → UpdraftPlus Backups → Existing Backups → Upload backup files** +3. Upload all five files you downloaded in Step 1 +4. After upload, click **Restore** and select all components +5. UpdraftPlus will restore your database and files +6. Log out and log back in — you'll use your **original** WordPress username and password now + +### If you used All-in-One WP Migration + +1. Install **All-in-One WP Migration** on the fresh Arcline WordPress installation +2. Go to **All-in-One WP Migration → Import** +3. Upload the `.wpm` file +4. Confirm the overwrite warning +5. Log out and log back in with your original credentials + +### If you exported manually + +1. Delete the `wp-content` folder on your new Arcline installation (via SFTP or file manager) +2. Upload the `wp-content` folder from your managed host backup +3. In phpMyAdmin on Arcline, select the new WordPress database → **Import** → upload the `.sql` file +4. Update `wp-config.php` to match the database credentials you created in Arcline cPanel: + +```php +define( 'DB_NAME', 'arclineuser_wpdb' ); +define( 'DB_USER', 'arclineuser_wpuser' ); +define( 'DB_PASSWORD', 'your-password' ); +define( 'DB_HOST', 'localhost' ); +``` + +On WP Engine and Kinsta, `DB_HOST` is **not** `localhost` — it's a remote database server address. You must change this to `localhost` for Arcline. + +--- + +## Step 4 — Update URLs in the database + +Managed hosts often use a temporary or staging domain before your real domain points to them. If your WordPress database still references an old URL, update it. + +After importing, log in to your WordPress admin and install **Better Search Replace**. Search for the old URL (e.g., `yourdomain.wpengine.com` or `staging-yourdomain.kinsta.cloud`) and replace it with `yourdomain.com` across all tables. + +Alternatively, use WP-CLI over SSH: + +```bash +wp search-replace 'yourdomain.wpengine.com' 'yourdomain.com' --all-tables +wp search-replace 'staging-yourdomain.kinsta.cloud' 'yourdomain.com' --all-tables +``` + +--- + +## Step 5 — Disable managed-host-specific plugins + +WP Engine and Kinsta install plugins that connect to their infrastructure. These won't work on Arcline and should be removed: + +**WP Engine:** +- **WP Engine Smart Plugin Manager** — manages updates through WP Engine's infrastructure +- **WP Engine Automated Migration** — only connects to WP Engine + +**Kinsta:** +- **Kinsta MU Plugin** (must-use plugin) — handles Kinsta's caching and CDN integration. Delete it from `/wp-content/mu-plugins/` +- **Kinsta Cache Plugin** — only works on Kinsta's infrastructure + +Remove these from WordPress admin → Plugins. For must-use plugins, delete them via SFTP from `wp-content/mu-plugins/`. + +--- + +## Step 6 — Set up caching + +Managed hosts include server-level caching. On Arcline, you'll need to set this up yourself: + +1. Install **W3 Total Cache** or **WP Super Cache** (both free) +2. Configure page caching and browser caching — default settings are fine for most sites +3. For VPS users: also consider PHP-FPM opcache and Nginx fastcgi_cache + +--- + +## Step 7 — Test before switching DNS + +Before pointing your domain to Arcline, test using your `hosts` file. + +Find your server IP in Arcline cPanel → **Server Information** (or use your VPS IP). + +Add this line to your hosts file: + +``` +YOUR_ARCLINE_IP yourdomain.com www.yourdomain.com +``` + +Now visit `https://yourdomain.com` in your browser. Verify: + +- The site loads correctly +- All pages, posts, and media display +- WordPress admin works (use your original credentials) +- Plugins and themes function +- Any e-commerce checkout, forms, and membership features work + +Remove the `hosts` entry after testing. + +--- + +## Step 8 — Switch DNS/nameservers + +**If your domain is managed at the managed host:** + +- **WP Engine:** WP Engine doesn't do domain registration. Your domain is likely at a separate registrar (GoDaddy, Namecheap, etc.). Update nameservers there. +- **Kinsta:** Kinsta doesn't register domains. Manage DNS at your registrar. + +**At your registrar**, change nameservers to: + +``` +ns1.arcline.it +ns2.arcline.it +``` + +See [Point Your Domain to Arcline](/getting-started/nameservers/) for registrar-specific instructions. + +**If you use Cloudflare:** + +Keep Cloudflare nameservers and update the A record to point to your Arcline IP. Remove any WP Engine or Kinsta-specific DNS records. + +DNS propagation takes 15 minutes to a few hours. Keep your managed host active during the transition. + +--- + +## Step 9 — Email + +Managed WordPress hosts typically **don't** include email hosting. If you had email set up elsewhere (Google Workspace, Microsoft 365, your registrar), it won't be affected by this migration. + +If you want to set up email on Arcline after migration, see [Set Up Email on Your Domain](/getting-started/email-setup/). You'll create accounts in Arcline cPanel and, if your DNS is managed externally, add the required MX records. + +--- + +## WP Engine-specific notes + +**Git push deployment** — WP Engine supports deploying via Git. This doesn't transfer; you'll use SFTP or standard WordPress workflows on Arcline. + +**WP Engine CDN** — WP Engine includes a CDN. On Arcline, you can optionally set up Cloudflare (free tier) if you want CDN coverage. + +**WP Engine's redirect rules** — if you set up redirects through WP Engine's rules engine, recreate them in your `.htaccess` file on Arcline. + +**Staging → Production** — WP Engine's three-environment setup (dev/staging/production) is managed by their platform. On Arcline, set up a staging subdomain manually if you need a test environment. + +--- + +## Kinsta-specific notes + +**Kinsta CDN** — Kinsta includes Cloudflare-powered CDN. Similar to WP Engine, you can optionally set up Cloudflare independently on Arcline. + +**Kinsta's Redis caching** — Kinsta uses Redis for object caching. On Arcline, install a Redis object cache plugin (like **Redis Object Cache**) if your plan or VPS includes Redis. For shared hosting without Redis, the standard page caching plugins are sufficient. + +**Kinsta's New Relic monitoring** — Kinsta includes performance monitoring. On Arcline, use Query Monitor (free WordPress plugin) for basic performance debugging. + +**PHP 8.x** — Kinsta pushes PHP 8.x aggressively. Arcline supports PHP 8.1 and 8.2 on all shared plans and VPS instances. Your site should run without PHP version issues. + +**IonCube and other loaders** — Kinsta doesn't support IonCube or SourceGuardian. Neither does standard Arcline shared hosting. If your site requires these encoders, you'll need a VPS where you can install them. + +--- + +## After migration checklist + +- [ ] Site loads at `https://yourdomain.com` +- [ ] WordPress admin works with original credentials +- [ ] All plugins and themes function +- [ ] Managed-host plugins removed (WP Engine MU plugin, Kinsta cache plugin) +- [ ] Caching plugin installed and configured +- [ ] SSL certificate active (AutoSSL or Let's Encrypt) +- [ ] Email accounts set up (if using Arcline email) +- [ ] DNS propagated (check at dnschecker.org) +- [ ] Managed host account kept active for 48 hours as fallback +- [ ] Managed host billing cancelled only after confirming everything works + diff --git a/content/migrate/transfer-domain.md b/content/migrate/transfer-domain.md new file mode 100644 index 0000000..5dfdc54 --- /dev/null +++ b/content/migrate/transfer-domain.md @@ -0,0 +1,203 @@ +--- +title: "Transfer a Domain to Arcline" +description: "How to transfer your domain registration to Arcline's registrar service — what you need to unlock, what it costs, and what to expect during the transfer." +section: migrate +order: 7 +--- + +# Transfer a Domain to Arcline + +Domain transfers move your domain **registration** from one registrar to another. It's separate from moving your website hosting — you can host at Arcline while keeping your domain registered wherever it is now, or you can transfer the registration to Arcline so everything is in one place. + +--- + +## Before you transfer + +A domain transfer typically takes **5–7 days**. During that time: + +- Your website and email **are not affected** if DNS records stay the same +- You can't update nameservers or DNS records at the old registrar +- The domain is locked for 60 days after the transfer completes (ICANN rule) + +**Transfers are not free.** ICANN charges registries a transfer fee, which becomes the renewal cost. When you transfer a domain to Arcline, you pay for **one additional year of registration** added to your current expiration date. If your domain expires in 6 months, after the transfer it will expire in 18 months. + +--- + +## Step 1 — Check eligibility + +A domain must meet these requirements to transfer: + +- **Registered more than 60 days ago** — ICANN prohibits transfers within 60 days of initial registration +- **Not transferred in the last 60 days** — same lock applies after a previous transfer +- **More than 15 days from expiration** — most registrars block transfers of domains about to expire +- **Not in redemption or pending delete status** — the domain must be active + +You can check your domain's registration date and status via WHOIS: + +``` +whois yourdomain.com +``` + +Or use a web-based WHOIS lookup at [whois.icann.org](https://whois.icann.org). + +--- + +## Step 2 — Prepare the domain at your current registrar + +### Unlock the domain + +Most registrars keep domains locked to prevent unauthorized transfers. Find the lock setting: + +- **GoDaddy:** Domain Settings → Domain lock → toggle off +- **Namecheap:** Domain List → Manage → Sharing & Transfer → unlock +- **Google Domains:** DNS → Registration settings → unlock +- **Cloudflare:** Domain Registration → Manage → Configuration → unlock +- **Other registrars:** look for "Domain lock", "Registrar lock", "Transfer lock", or "Theft protection" + +The lock may take a few minutes to a few hours to release. + +### Get the authorization code (EPP code) + +The authorization code (also called EPP code or transfer key) is a one-time code required to approve the transfer: + +- **GoDaddy:** Domain Settings → Transfer domain away from GoDaddy → Get authorization code +- **Namecheap:** Domain List → Manage → Transfer Out → Request EPP code (sent to your email) +- **Google Domains:** DNS → Registration settings → Get authorization code +- **Cloudflare:** Domain Registration → Manage → Configuration → Request auth code +- **Other registrars:** look for "Transfer out", "Authorization code", "EPP code", or "Auth info" + +The code is typically a string of 6–32 characters. Save it — you'll need it in the next step. + +### Verify admin contact email + +Transfer confirmations are sent to the domain's **admin contact email** listed in WHOIS. Make sure this email address: + +- Is one you have access to +- Isn't an email address at the domain you're transferring (e.g., `admin@yourdomain.com`) — because if DNS changes, you might miss the confirmation email + +If needed, update the admin contact email at your current registrar before starting the transfer. + +### Disable WHOIS privacy (if required) + +Some registrars require WHOIS privacy to be disabled during a transfer. Check your registrar's transfer documentation. If required: + +- **GoDaddy:** Domain Settings → WHOIS privacy → turn off +- **Namecheap:** Domain List → Manage → Domain Privacy → disable +- **Others:** look for "WHOIS privacy", "Privacy protection", or "ID protection" + +Turn it back on after the transfer completes. + +--- + +## Step 3 — Initiate the transfer at Arcline + +Contact Arcline support to start a domain transfer. Provide: + +- The domain name +- The authorization code (EPP code) from Step 2 +- Confirmation that the domain is unlocked + +Arcline will initiate the transfer through our registrar. The cost is one year's renewal fee, which will be added to your Arcline invoice. + +--- + +## Step 4 — Approve the transfer + +After Arcline initiates the transfer, you'll receive two emails: + +1. **From your current registrar** — asking for confirmation that you want to transfer the domain away. You must click the approval link within a few days, or the transfer cancels. +2. **From the registry** (Verisign for `.com`/`.net`, Public Interest Registry for `.org`, etc.) — confirming the transfer has been requested. + +Follow the instructions in both emails. If you don't receive them within a few hours, check your spam folder and verify the admin contact email is correct. + +Some registrars (GoDaddy, Namecheap) allow you to approve the transfer from their dashboard instead of waiting for the email. If that option is available, use it — it's faster. + +--- + +## Step 5 — Wait for the transfer to complete + +Once approved, the transfer takes **5 days by default**. The losing registrar can release the domain sooner, but most of them wait the full period as a security measure. + +During this time: + +- Your website and email **continue working normally** — the transfer only affects the registration, not DNS +- You can't change nameservers at your current registrar +- The domain shows a "pending transfer" status in WHOIS + +If your domain's DNS is managed at your current registrar, your DNS records are copied as-is and continue pointing wherever they pointed before. Nothing breaks during a transfer. + +--- + +## Step 6 — After the transfer completes + +Once Arcline confirms the transfer is complete: + +1. **Verify your DNS records** — log in to your Arcline account and check that your DNS zone is correctly set up with the records you need (A, MX, CNAME, TXT, etc.) +2. **Update nameservers** — if you want DNS managed by Arcline, make sure nameservers are set to `ns1.arcline.it` and `ns2.arcline.it` +3. **Re-enable WHOIS privacy** — turn privacy protection back on if you want it +4. **Check email** — if your email uses the domain (e.g., `you@yourdomain.com`), verify that MX records are present and mail is flowing + +--- + +## Transferring from specific registrars + +### GoDaddy + +GoDaddy often tries to retain domains by making the transfer process confusing. The key steps: + +1. Unlock: Domain Settings → scroll to **Domain lock** → toggle off. Wait 10 minutes. +2. Auth code: GoDaddy calls it the **Authorization Code**. They'll email the admin contact with a link to retrieve it. +3. After initiating: GoDaddy sends a confirmation email with a link to approve the transfer. If you ignore this email, the transfer **will still complete after 5 days** — GoDaddy changed this policy and now auto-approves transfers after the waiting period. + +### Namecheap + +1. Unlock: Domain List → Manage → Sharing & Transfer → unlock +2. Auth code: Request via **Transfer Out** tab → **Request EPP Code** (emailed to you) +3. Namecheap's confirmation email includes an "Approve Transfer" link — click it for a faster transfer + +### Google Domains + +1. Unlock: DNS → Registration settings → unlock +2. Auth code: **Get authorization code** in Registration settings +3. Google Domains generally processes transfers quickly + +### Cloudflare (as registrar) + +1. Unlock: Domain Registration → Manage → Configuration → unlock +2. Auth code: **Request auth code** in Configuration +3. Cloudflare automatically removes your DNS zone after a successful transfer, which can cause downtime. Before transferring away from Cloudflare, export your DNS records (Cloudflare dashboard → DNS → Export) so you can re-import them at Arcline. + +--- + +## What if the transfer fails? + +Common reasons for transfer failure and how to fix them: + +| Problem | Fix | +|---|---| +| Domain is locked | Unlock at current registrar and try again | +| Wrong authorization code | Request a new one from the current registrar | +| Domain registered less than 60 days ago | Wait until 60 days have passed since registration | +| Transferred within last 60 days | Wait until 60 days have passed since the previous transfer | +| Domain expired | Renew at current registrar first, then transfer | +| Admin contact didn't approve | Check email and spam folder. Ask Arcline support to resend | +| WHOIS privacy blocking | Disable privacy protection at current registrar | + +Contact Arcline support if a transfer fails unexpectedly — we can check the exact reason and help you fix it. + +--- + +## Transfer vs. just changing nameservers + +You don't need to transfer your domain to host at Arcline. The alternative is simply updating your nameservers to `ns1.arcline.it` and `ns2.arcline.it` at your current registrar (see [Point Your Domain to Arcline](/getting-started/nameservers/)). + +**Transfer when:** +- You want all billing in one place (Arcline) +- Your current registrar has raised prices or has bad support +- You're consolidating after an acquisition (Google Domains → Squarespace, etc.) + +**Just change nameservers when:** +- You like your current registrar +- The domain isn't eligible for transfer yet (within 60-day lock) +- You want to keep registration and hosting with different providers + diff --git a/content/privacy/arcline-check-walkthrough.md b/content/privacy/arcline-check-walkthrough.md new file mode 100644 index 0000000..ab7c639 --- /dev/null +++ b/content/privacy/arcline-check-walkthrough.md @@ -0,0 +1,160 @@ +--- +title: "How to Check if Your Host is Self-Hosted" +description: "Use arcline-check to detect whether a website is truly self-hosted or routed through a CDN/cloud provider." +section: privacy +order: 2 +--- + +# How to Check if Your Host is Self-Hosted + +The `arcline-check` tool tells you whether a domain is truly self-hosted or routing through Cloudflare, Fastly, AWS CloudFront, or another CDN/cloud provider. + +--- + +## What arcline-check does + +When you run `arcline-check example.com`, it: + +1. Resolves the domain to its IP address +2. Performs a reverse DNS lookup (PTR record) +3. Looks up the ASN (Autonomous System Number) and organization +4. Checks whether the IP falls within known CDN/cloud provider CIDR ranges +5. Inspects HTTP response headers for CDN signatures (CF-Ray, X-Served-By, etc.) +6. Produces a color-coded terminal report + +This is especially useful for: +- Evaluating potential hosting providers during migration +- Verifying a host's claims about being "self-hosted" +- Understanding your own site's infrastructure + +--- + +## Installation + +```bash +# Download the latest binary +wget https://git.arcline.it/arcline/arcline-check/releases/latest/download/arcline-check-linux-amd64 + +# Make it executable +chmod +x arcline-check-linux-amd64 + +# Move to your PATH +sudo mv arcline-check-linux-amd64 /usr/local/bin/arcline-check +``` + +Or build from source: + +```bash +git clone https://git.arcline.it/arcline/arcline-check.git +cd arcline-check +go build -o arcline-check . +``` + +--- + +## Basic usage + +```bash +arcline-check arcline.it +``` + +Example output: + +``` + domain arcline.it + resolved 203.0.113.42 + rdns server1.arclineit.com + asn AS64496 Example ISP + org Example ISP LLC + + [OK] not behind a known CDN + [OK] no Cloudflare headers detected + [OK] IP not in AWS/GCP/Azure ranges +``` + +--- + +## Checking a site behind Cloudflare + +```bash +arcline-check example-cloudflare-site.com +``` + +Example output: + +``` + domain example-cloudflare-site.com + resolved 104.16.42.42 + rdns 104.16.42.42 (no PTR) + asn AS13335 Cloudflare, Inc. + org Cloudflare + + [FAIL] behind Cloudflare (CDN) + [FAIL] CF-Ray header detected + [FAIL] IP in Cloudflare CIDR range +``` + +--- + +## JSON output for scripting + +```bash +arcline-check example.com --json +``` + +```json +{ + "domain": "example.com", + "ip": "203.0.113.42", + "rdns": "server1.arclineit.com", + "asn": "AS64496", + "org": "Example ISP LLC", + "cdn_detected": false, + "headers": { + "server": "nginx/1.24.0" + } +} +``` + +--- + +## Watch mode for DNS migration monitoring + +During a DNS migration, use `--watch` to see when propagation completes: + +```bash +arcline-check example.com --watch 30 +``` + +This re-checks every 30 seconds. When the IP changes from the old provider to the new one, the output updates in place. + +--- + +## Interpreting the results + +| Indicator | What it means | +|-----------|---------------| +| **IP in Cloudflare range** | The site is behind Cloudflare's proxy (orange cloud) | +| **CF-Ray header** | Cloudflare is terminating the connection | +| **IP in AWS/GCP/Azure range** | The server is a cloud VM, not self-hosted hardware | +| **IP in a residential/business ISP range** | Likely self-hosted (on-premises or colocated) | +| **PTR matches domain** | Good operational practice — the host configured rDNS | +| **No CDN detected** | Traffic goes directly to the origin server | + +--- + +## Limitations + +- A CDN-detected result doesn't always mean bad hosting — some providers use CDNs for legitimate DDoS protection +- Arcline doesn't use any CDN by default, but customers are free to add one if they choose +- The tool can't detect every possible CDN or proxy — new providers are added regularly +- If a site uses Cloudflare spectrum or TCP tunnels, it may appear self-hosted even though Cloudflare is involved + +--- + +## What's next + +- [Self-hosting without a CDN: performance tips](/privacy/self-hosting-performance/) +- [Why you shouldn't put Cloudflare in front of everything](/privacy/why-not-cloudflare/) +- [What SPF, DKIM, and DMARC actually do](/privacy/spf-dkim-dmarc/) + diff --git a/content/privacy/self-hosting-performance.md b/content/privacy/self-hosting-performance.md new file mode 100644 index 0000000..a3de646 --- /dev/null +++ b/content/privacy/self-hosting-performance.md @@ -0,0 +1,221 @@ +--- +title: "Self-Hosting Without a CDN: Performance Tips" +description: "How to make your self-hosted site fast without relying on Cloudflare or other CDNs." +section: privacy +order: 3 +--- + +# Self-Hosting Without a CDN: Performance Tips + +A common objection to self-hosting is "but it won't be fast without a CDN." That's not true for most sites. With proper configuration, a well-tuned Nginx server on a decent VPS will load pages in under 200ms for visitors on the same continent — more than fast enough for a great user experience. + +--- + +## Tip 1 — Enable HTTP/2 and HTTP/3 + +HTTP/2 multiplexes requests over a single connection. HTTP/3 (QUIC) reduces latency even further. + +```nginx +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + # HTTP/3 requires Nginx 1.25+ with quic support + listen 443 quic reuseport; +} +``` + +Check that HTTP/2 is active by looking at the Chrome DevTools → Network tab — your requests should show `h2` as the protocol. + +--- + +## Tip 2 — Aggressive static file caching + +Serve static assets with long cache lifetimes and immutable headers so browsers never re-request them: + +```nginx +location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; +} +``` + +With immutable caching, the browser will never check if the file has changed until the user force-reloads. + +--- + +## Tip 3 — Enable Gzip compression + +Compress text-based responses: + +```nginx +gzip on; +gzip_vary on; +gzip_proxied any; +gzip_comp_level 5; +gzip_min_length 256; +gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + image/svg+xml; +``` + +This can reduce HTML, CSS, and JS payloads by 60–80%. + +--- + +## Tip 4 — Use a PHP opcode cache + +For WordPress and other PHP sites, enable OPcache: + +```ini +; /etc/php/8.3/cli/conf.d/10-opcache.ini +opcache.enable=1 +opcache.memory_consumption=128 +opcache.max_accelerated_files=10000 +opcache.revalidate_freq=120 +``` + +This keeps compiled PHP scripts in memory, avoiding recompilation on every request. + +--- + +## Tip 5 — Install a WordPress caching plugin + +For WordPress sites, use a caching plugin that generates static HTML: + +- **WP Super Cache** or **W3 Total Cache**: Generate static HTML files served directly by Nginx +- **LiteSpeed Cache**: If you're running LiteSpeed (LSAPI mode) + +See our [W3 Total Cache Configuration](/wordpress/w3-total-cache/) guide for detailed setup instructions without a CDN. + +--- + +## Tip 6 — Tune Nginx worker settings + +```nginx +worker_processes auto; +worker_rlimit_nofile 65535; + +events { + worker_connections 4096; + use epoll; + multi_accept on; +} +``` + +`auto` sets the worker count to the number of CPU cores. `worker_connections 4096` allows each worker to handle 4,000 concurrent connections. + +--- + +## Tip 7 — Serve images efficiently + +- **Use WebP** instead of JPEG/PNG (30% smaller). Convert with `cwebp`: + ```bash + sudo apt install webp -y + cwebp -q 80 input.jpg -o output.webp + ``` +- **Use responsive images** with srcset so mobile devices don't download desktop-sized images +- **Lazy-load below-the-fold images** with `loading="lazy"` attribute: + ```html + ... + ``` +- **Compress images** with a tool like ImageMagick or optipng before uploading + +--- + +## Tip 8 — Optimize your database + +For MySQL/MariaDB: +- Enable query cache (MySQL 5.7 and earlier) +- Run `mysqlcheck -o --all-databases` weekly +- Remove unused plugins and post revisions in WordPress +- Use an index on frequently queried columns + +```sql +-- Clean up WordPress post revisions +DELETE FROM wp_posts WHERE post_type = 'revision'; + +-- Optimize tables +OPTIMIZE TABLE wp_posts, wp_postmeta; +``` + +--- + +## Tip 9 — Set proper buffer sizes + +```nginx +client_body_buffer_size 128k; +client_max_body_size 50m; +client_header_buffer_size 1k; +large_client_header_buffers 4 8k; +output_buffers 32 32k; +postpone_output 1460; +``` + +These settings prevent Nginx from buffering more data than necessary per connection. + +--- + +## Tip 10 — Choose a geographically close VPS location + +Arcline's data center location matters. A VPS in Dallas will serve US visitors faster than one in Frankfurt. For international audiences, consider: + +- One VPS in the US + one in Europe with a simple round-robin DNS +- Or just accept slightly higher latency for overseas visitors — a 200ms response time is still perfectly usable + +--- + +## Benchmarking your setup + +After making these changes, test your performance: + +```bash +# Install siege for load testing +sudo apt install siege -y +siege -c 50 -t 60s https://example.com + +# Or use curl to measure response time +curl -w "@curl-format.txt" -o /dev/null -s https://example.com +``` + +Create a format file (`curl-format.txt`): + +``` + time_namelookup: %{time_namelookup}s + time_connect: %{time_connect}s + time_appconnect: %{time_appconnect}s + time_redirect: %{time_redirect}s +time_pretransfer: %{time_pretransfer}s + time_starttransfer: %{time_starttransfer}s + ---------- + time_total: %{time_total}s +``` + +--- + +## Real-world results + +A typical Arcline VPS running WordPress with: +- Nginx + PHP 8.3 FPM +- OPcache enabled +- W3 Total Cache (page cache + database cache) +- WebP images +- Gzip compression + +Will serve pages in **150–300ms** to US visitors and handle **500+ concurrent users** on a $15/mo plan. + +You don't need Cloudflare to be fast. You need a well-configured server. + +--- + +## What's next + +- [What SPF, DKIM, and DMARC actually do](/privacy/spf-dkim-dmarc/) +- [Why you shouldn't put Cloudflare in front of everything](/privacy/why-not-cloudflare/) +- [How to check if a host is self-hosted](/privacy/arcline-check-walkthrough/) + diff --git a/content/privacy/spf-dkim-dmarc.md b/content/privacy/spf-dkim-dmarc.md new file mode 100644 index 0000000..a52cff1 --- /dev/null +++ b/content/privacy/spf-dkim-dmarc.md @@ -0,0 +1,139 @@ +--- +title: "What SPF, DKIM, and DMARC Actually Do" +description: "An explanation of email authentication standards and how to set them up on Arcline." +section: privacy +order: 4 +--- + +# What SPF, DKIM, and DMARC Actually Do + +Email authentication standards (SPF, DKIM, DMARC) prevent spammers from sending email that looks like it comes from your domain. Without them, your outgoing email is more likely to land in spam folders — or worse, be used to impersonate you. + +--- + +## SPF — Sender Policy Framework + +**What it does**: SPF publishes a list of IP addresses that are authorized to send email for your domain. + +**How it works**: +1. A receiving mail server receives a message claiming to be from `@yourdomain.com` +2. The receiving server looks up your SPF record at `yourdomain.com` +3. If the sending IP is in the SPF record, the message passes. If not, it's subject to the server's spam policy. + +**An SPF record looks like**: + +``` +v=spf1 mx ip4:203.0.113.42 ~all +``` + +This means: "Allow mail from your MX servers and from IP 203.0.113.42. Soft-fail everything else." + +**Setting it up on Arcline**: + +In your Arcline cPanel, go to **Domains → Zone Editor** and add a TXT record: + +| Type | Name | Value | +|------|------|-------| +| TXT | `@` | `v=spf1 mx include:arclineit.com ~all` | + +The `include:arclineit.com` will pull in Arcline's sending IPs automatically. + +--- + +## DKIM — DomainKeys Identified Mail + +**What it does**: DKIM signs your outgoing email with a cryptographic signature. The receiving server verifies the signature by looking up your public key in DNS. + +**How it works**: +1. Arcline's mail server signs your outgoing message with a private key +2. The receiving server looks up your public DKIM key at `selector._domainkey.yourdomain.com` +3. It decrypts the signature and verifies the message wasn't tampered with in transit + +**Setting it up on Arcline**: + +Arcline cPanel automatically generates DKIM keys. To verify they're set up: + +1. cPanel → **Email → Email Deliverability** +2. Find your domain and click **Manage** +3. You should see a green status for DKIM + +The DNS record is automatically added. It looks like: + +| Type | Name | Value | +|------|------|-------| +| TXT | `arcline._domainkey` | `v=DKIM1; h=sha256; p=MIGfMA0GCSqGSIb4DQEBAQUAA4GNADCBiQKBgQ...` | + +--- + +## DMARC — Domain-based Message Authentication, Reporting & Conformance + +**What it does**: DMARC tells receiving servers what to do when a message fails SPF or DKIM checks. It also provides reports so you can see who's sending email on your behalf. + +**How it works**: +1. A message arrives claiming to be from your domain +2. The receiving server checks SPF and DKIM +3. If both pass, the DMARC policy doesn't matter +4. If one or both fail, the server follows your DMARC policy: + - `none` — Take no action (just report) + - `quarantine` — Mark as spam + - `reject` — Reject the message outright + +**Setting it up on Arcline**: + +Start with `p=none` to see who's sending email for your domain without blocking anything. After a few weeks, review the reports and tighten to `p=quarantine`. After confirming all legitimate email is authenticated, move to `p=reject`. + +| Type | Name | Value | +|------|------|-------| +| TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com` | + +The `rua` field specifies where you want to receive aggregate DMARC reports. Tools like [dmarcian.com](https://dmarcian.com) or [Postmark's DMARC tool](https://dmarc.postmarkapp.com) can help you parse them. + +--- + +## All three records together + +For `yourdomain.com`, your DNS zone should have these TXT records: + +``` +yourdomain.com. TXT "v=spf1 mx include:arclineit.com ~all" +arcline._domainkey.yourdomain.com. TXT "v=DKIM1; h=sha256; p=..." +_dmarc.yourdomain.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com" +``` + +--- + +## Testing your setup + +Use one of these free tools to verify everything is working: + +```bash +# Command line +dig TXT yourdomain.com +short +dig TXT arcline._domainkey.yourdomain.com +short +dig TXT _dmarc.yourdomain.com +short +``` + +Or visit: +- [MXToolbox](https://mxtoolbox.com/diagnostic.aspx) — enter your domain +- [Mail-Tester](https://www.mail-tester.com) — send a test email to the address shown + +--- + +## Common issues + +| Symptom | Likely cause | +|---------|--------------| +| SPF passes but emails still go to spam | Missing or misconfigured DKIM | +| DKIM passes but emails go to spam | Missing DMARC policy | +| DMARC reports show IPs you don't recognize | Someone is spoofing your domain — set `p=reject` | +| DMARC reports show a legitimate service failing | Add the service's IPs to your SPF record | +| Automated reports from third-party services | Their infrastructure needs to be included in your SPF record — contact them for their SPF include | + +--- + +## What's next + +- [Why you shouldn't put Cloudflare in front of everything](/privacy/why-not-cloudflare/) +- [Self-hosting without a CDN: performance tips](/privacy/self-hosting-performance/) +- [Set up email on Arcline](/getting-started/email-setup/) + diff --git a/content/privacy/why-not-cloudflare.md b/content/privacy/why-not-cloudflare.md new file mode 100644 index 0000000..bf1dfd4 --- /dev/null +++ b/content/privacy/why-not-cloudflare.md @@ -0,0 +1,101 @@ +--- +title: "Why You Shouldn't Put Cloudflare in Front of Everything" +description: "The hidden costs of relying on Cloudflare: privacy concerns, vendor lock-in, and single points of failure." +section: privacy +order: 1 +--- + +# Why You Shouldn't Put Cloudflare in Front of Everything + +Cloudflare is popular — it's fast, free, and easy to set up. But the convenience comes with tradeoffs that matter, especially for a hosting provider that prides itself on self-hosting and privacy. + +--- + +## The single point of failure + +When you put Cloudflare in front of your origin server, Cloudflare becomes your infrastructure. If Cloudflare has an outage (and they've had several), your site goes down even if your server is perfectly healthy. + +In 2022, a Cloudflare global outage took down 20% of the internet's top million sites. Customers couldn't access their own servers because DNS wasn't resolving. If you self-host on Arcline, your DNS and traffic should be under your control. + +--- + +## Cloudflare terminates TLS + +This is the most important point for privacy-conscious site owners. When you use Cloudflare's proxy (orange cloud), Cloudflare terminates the TLS connection. Traffic between Cloudflare and your origin server travels in plaintext unless you set up a separate origin certificate. + +This means Cloudflare can: +- Read all traffic passing through their network +- Inject JavaScript into your pages (which they do for features like Rocket Loader and bot management) +- Modify your HTML, images, and CSS +- See every visitor's IP address, browser, and behavior + +If you're hosting anything sensitive — even a personal blog — you're trusting Cloudflare with all of it. + +--- + +## Vendor lock-in + +Once you're deep in the Cloudflare ecosystem, leaving becomes painful: + +- **DNS records**: You manage DNS inside Cloudflare, not on your authoritative nameservers +- **Page Rules**: Caching, redirects, and rewriting rules are configured in Cloudflare's interface +- **SSL certificates**: Cloudflare issues and manages certs for your domains +- **Workers**: Serverless functions that only run on Cloudflare's network +- **Analytics**: Historical data lives in Cloudflare, not on your servers +- **Email routing**: Cloudflare's email forwarding is a sticky dependency + +Each of these features makes Cloudflare harder to walk away from. The free tier becomes a golden handcuff. + +--- + +## Privacy implications + +Cloudflare sits between your visitors and your server. This means: + +- Every visitor's IP address is visible to Cloudflare (they claim not to sell it, but you're still sharing it) +- Cloudflare can build behavioral profiles across the millions of sites on their network +- Visitors on Cloudflare see "This site is powered by Cloudflare" banners and CAPTCHAs +- The more sites that use Cloudflare, the more centralized the internet becomes + +For a hosting company that markets itself on privacy and independence, directing customers to Cloudflare undermines the entire value proposition. + +--- + +## When Cloudflare makes sense + +There are legitimate use cases: + +- **DDoS protection**: Cloudflare's network can absorb attacks that would overwhelm a single server +- **Global CDN**: If most of your visitors are on another continent, Cloudflare's edge cache speeds up delivery +- **API protection**: Rate limiting and bot detection at the edge + +But these should be deliberate choices for specific problems, not a default for every site. + +--- + +## The Arcline approach + +Arcline is built on the principle that you should control your own infrastructure: + +- DNS is hosted on Arcline's own authoritative nameservers (NSD) +- TLS terminates at your server — no third party sees your traffic +- Static sites are served directly by Nginx, without an intermediary +- Monitoring and analytics are self-hosted (Prometheus + arcline-uptime) + +This means you don't get Cloudflare's global CDN or DDoS scrubbing by default. What you get is: + +- Full control over your traffic +- No third party reading your visitors' data +- No vendor lock-in +- Complete architectural flexibility + +For most small to medium sites, a single VPS with proper Nginx tuning, a good caching strategy, and fail2ban for security is more than adequate. See our [Self-Hosting Performance Tips](/privacy/self-hosting-performance/) guide for making the most of it. + +--- + +## What's next + +- [How to check if a host is self-hosted](/privacy/arcline-check-walkthrough/) — use arcline-check +- [Self-hosting without a CDN: performance tips](/privacy/self-hosting-performance/) +- [What SPF, DKIM, and DMARC actually do](/privacy/spf-dkim-dmarc/) + diff --git a/content/reference/acceptable-use-policy.md b/content/reference/acceptable-use-policy.md new file mode 100644 index 0000000..ce298df --- /dev/null +++ b/content/reference/acceptable-use-policy.md @@ -0,0 +1,82 @@ +--- +title: "Acceptable Use Policy Summary" +description: "What's allowed and what's not on Arcline hosting. A plain-English summary of the AUP." +section: reference +order: 4 +--- + +# Acceptable Use Policy Summary + +This is a plain-English summary. The full Acceptable Use Policy is available at [arcline.it/aup](https://arcline.it/aup). + +--- + +## Prohibited content + +You may not use Arcline services to host or distribute: + +- **Illegal content**: Copyright-infringing material, child exploitation content, malware, stolen data +- **Spam**: Unsolicited bulk email, email lists purchased from third parties, auto-generated content farms +- **Phishing or fraud**: Sites that impersonate legitimate services to steal credentials +- **Cryptocurrency mining**: Mining on shared hosting or VPS without explicit permission +- **Open relays**: Mail servers configured to allow third-party relaying +- **DDoS tools**: Any software designed to launch denial-of-service attacks +- **Terrorist or extremist content**: As defined by applicable law + +--- + +## Prohibited activities + +| Activity | Allowed? | Notes | +|----------|----------|-------| +| Personal blog | ✅ Yes | | +| Business website | ✅ Yes | | +| E-commerce store | ✅ Yes | | +| Video sharing | ⚠️ Limited | Shared hosting has bandwidth limits. VPS may be suitable. | +| File hosting | ⚠️ Limited | Personal files only. No public file dumps or CDN-like services. | +| Game servers | ⚠️ Requires VPS | Not allowed on shared hosting | +| VPN server | ⚠️ Requires VPS | Allowed on VPS for personal use | +| Tor exit node | ❌ No | | +| Email marketing | ✅ With limitations | Must comply with CAN-SPAM. No purchased lists. | +| Mailing list hosting | ✅ With limitations | Double opt-in required. Must have unsubscribe mechanism. | +| Proxy service | ❌ No | Public web proxies are not allowed | +| Botnet/C2 | ❌ No | Obviously | + +--- + +## Resource usage + +- **Shared hosting**: Your site may not use excessive CPU, RAM, or I/O that affects other customers. Sites that consistently exceed resource limits will be asked to upgrade to a VPS. +- **VPS**: You get dedicated resources. There's no resource sharing, so the AUP limits are looser. However, your VPS may not be used for illegal activity or to attack other systems. +- **Email**: Sending more than 200 messages per hour triggers automatic throttling. Bulk senders must use a dedicated email service. + +--- + +## Enforcement + +If you violate the AUP: + +1. **First violation**: Warning email with a description of the issue and a request to resolve it within 24 hours +2. **Second violation**: Service suspension until the issue is resolved +3. **Severe violation (spam, phishing, malware)**: Immediate suspension without notice + +Arcline reserves the right to terminate service for repeated or severe violations without refund. + +--- + +## Reporting violations + +If you see content hosted on Arcline that violates the AUP: + +- **Email**: abuse@arclineit.com +- **DMCA takedown notices**: See our DMCA policy at [arcline.it/dmca](https://arcline.it/dmca) +- **Response time**: We typically respond to abuse reports within 4 hours + +--- + +## What's next + +- [How to open a support ticket](/reference/support-tickets/) +- [Arcline nameservers and DNS records](/reference/nameservers/) +- [Resource limits by plan](/reference/resource-limits/) + diff --git a/content/reference/nameservers.md b/content/reference/nameservers.md new file mode 100644 index 0000000..57adc79 --- /dev/null +++ b/content/reference/nameservers.md @@ -0,0 +1,77 @@ +--- +title: "Arcline Nameservers and DNS Records" +description: "List of Arcline nameservers and instructions for pointing your domain to Arcline." +section: reference +order: 1 +--- + +# Arcline Nameservers and DNS Records + +This page lists the Arcline authoritative nameservers and the DNS records you'll need for common services. + +--- + +## Authoritative nameservers + +Point your domain to Arcline using these nameservers: + +| Purpose | Nameserver | +|---------|------------| +| Primary | `ns1.arcline.it` | +| Secondary | `ns2.arcline.it` | + +Both nameservers serve all zones authoritatively. They run NSD on Arcline's own infrastructure. + +--- + +## How to point your domain + +At your registrar's control panel, replace the existing nameservers with: + +``` +ns1.arcline.it +ns2.arcline.it +``` + +DNS propagation typically takes 15 minutes to a few hours, but can take up to 48 hours in rare cases. See [Point Your Domain to Arcline](/getting-started/nameservers/) for registrar-specific walkthroughs. + +--- + +## Common DNS records + +### Website (A record) + +| Type | Name | Value | +|------|------|-------| +| A | `@` | `your-server-ip` | +| A | `www` | `your-server-ip` | + +### Email (MX records) + +| Type | Name | Priority | Value | +|------|------|----------|-------| +| MX | `@` | 0 | `mail.yourdomain.com` | + +### Email authentication (TXT records) + +| Type | Name | Value | +|------|------|-------| +| TXT | `@` | `v=spf1 mx include:arclineit.com ~all` | +| TXT | `arcline._domainkey` | `v=DKIM1; h=sha256; p=...` | +| TXT | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com` | + +### Subdomain examples + +| Type | Name | Value | +|------|------|-------| +| A | `portal` | `your-server-ip` | +| A | `git` | `your-server-ip` | +| CNAME | `docs` | `yourdomain.com` | +| CNAME | `status` | `yourdomain.com` | + +--- + +## Managing DNS records + +In Arcline cPanel, go to **Domains → Zone Editor** to add, edit, or remove DNS records. Changes are instant — NSD reloads the zone automatically. + diff --git a/content/reference/php-versions.md b/content/reference/php-versions.md new file mode 100644 index 0000000..20e97de --- /dev/null +++ b/content/reference/php-versions.md @@ -0,0 +1,63 @@ +--- +title: "Supported PHP Versions" +description: "PHP versions available on Arcline shared hosting and VPS plans." +section: reference +order: 2 +--- + +# Supported PHP Versions + +Arcline supports multiple PHP versions to accommodate different application requirements. + +--- + +## Shared hosting + +On shared hosting plans, you can select your PHP version in cPanel under **Software → Select PHP Version**. + +| Version | Status | End of Life | Notes | +|---------|--------|-------------|-------| +| 8.3 | Active | 2026-12-31 | Recommended for new sites | +| 8.2 | Active | 2025-12-31 | Good stability | +| 8.1 | Security only | 2024-11-30 | Upgrade recommended | +| 8.0 | End of life | 2023-11-26 | Not recommended | + +--- + +## VPS plans + +On VPS plans, you can install any PHP version from the system package manager or use a third-party repository like [Ondřej Surý's PPA](https://deb.sury.org/): + +```bash +sudo apt install php8.3 php8.3-fpm php8.3-mysql +``` + +--- + +## Switching PHP versions + +In cPanel, go to **Select PHP Version**, choose the version you want, and click **Set as current**. Most WordPress sites will work on any PHP 8.x version without issues. + +After switching, check your site thoroughly: +- If you see blank pages or errors, your application may not support the PHP version you selected +- WordPress 5.6+ supports PHP 8.x. WordPress 6.0+ requires PHP 7.4+ +- Older plugins may not be compatible with PHP 8.x — check with the plugin developer + +--- + +## PHP extensions + +The following extensions are available on shared hosting: + +``` +bcmath, calendar, Core, ctype, curl, date, dom, exif, fileinfo, +filter, ftp, gd, gettext, gmp, hash, iconv, imagick, intl, json, +ldap, libxml, mbstring, mysqli, mysqlnd, openssl, pcntl, pcre, +PDO, pdo_mysql, pdo_sqlite, Phar, posix, random, readline, redis, +Reflection, session, shmop, SimpleXML, sockets, sodium, SPL, sqlite3, +standard, sysvsem, sysvshm, tokenizer, xml, xmlreader, xmlwriter, +xsl, Zend OPcache, zip, zlib +``` + +If you need an extension that isn't listed, contact Arcline support. + diff --git a/content/reference/resource-limits.md b/content/reference/resource-limits.md new file mode 100644 index 0000000..291ec11 --- /dev/null +++ b/content/reference/resource-limits.md @@ -0,0 +1,79 @@ +--- +title: "Resource Limits by Plan" +description: "Storage, bandwidth, and resource limits for each Arcline hosting plan." +section: reference +order: 3 +--- + +# Resource Limits by Plan + +This page outlines the resource allocations for each Arcline hosting plan. Contact support if you need more information about a specific limit. + +--- + +## Shared hosting limits + +| Resource | Starter | Business | Professional | +|----------|---------|----------|--------------| +| Disk space | 10 GB | 25 GB | 50 GB | +| Bandwidth | 100 GB/mo | 250 GB/mo | 500 GB/mo | +| Websites | 1 | 5 | Unlimited | +| Email accounts | 5 | 25 | Unlimited | +| Databases | 5 | 25 | Unlimited | +| Subdomains | 5 | 25 | Unlimited | +| FTP accounts | 5 | 25 | Unlimited | +| Inode limit | 50,000 | 100,000 | 250,000 | +| PHP memory limit | 128 MB | 256 MB | 512 MB | +| PHP upload limit | 32 MB | 64 MB | 128 MB | +| CPU | Shared | Shared | Priority | +| RAM | Shared | Shared | Guaranteed | + +--- + +## VPS limits + +| Resource | VPS-1 | VPS-2 | VPS-4 | VPS-8 | +|----------|-------|-------|-------|-------| +| vCPU | 1 core | 2 cores | 4 cores | 8 cores | +| RAM | 1 GB | 2 GB | 4 GB | 8 GB | +| Storage | 25 GB SSD | 50 GB SSD | 100 GB SSD | 200 GB SSD | +| Bandwidth | 1 TB/mo | 2 TB/mo | 4 TB/mo | 8 TB/mo | +| Backup | Weekly | Daily | Daily | Daily | +| Snapshot | — | 1 | 2 | 4 | +| Dedicated IP | 1 | 1 | 2 | 2 | + +--- + +## Email limits + +| Resource | Limit | +|----------|-------| +| Mailbox size | 5 GB (shared), 10 GB (VPS) | +| Max attachment size | 25 MB | +| Max recipients per message | 100 | +| Max outgoing messages per hour | 200 | +| Max incoming messages per hour | 500 | +| Auto-responders | 10 per domain | +| Forwarders | 25 per domain | +| Mailing lists | 10 per domain | + +--- + +## DNS limits + +| Resource | Limit | +|----------|-------| +| Zone records per domain | 500 | +| TXT record length | 2048 characters | +| DNSSEC | Supported on all plans | +| Custom nameservers | Available on Business+ | + +--- + +## Acceptable use reminders + +- Shared hosting is not suitable for video streaming, file sharing, or high-traffic media sites +- All plans are subject to Arcline's [Acceptable Use Policy](/reference/acceptable-use-policy/) +- Resource usage is monitored automatically — excessive usage may result in throttling or plan upgrade requests +- VPS plans give you full root access — you manage your own resource allocation + diff --git a/content/reference/support-tickets.md b/content/reference/support-tickets.md new file mode 100644 index 0000000..aa6372d --- /dev/null +++ b/content/reference/support-tickets.md @@ -0,0 +1,111 @@ +--- +title: "How to Open a Support Ticket" +description: "How to contact Arcline support, what information to include, and expected response times." +section: reference +order: 5 +--- + +# How to Open a Support Ticket + +When you need help, opening a support ticket is the best way to get a response. This page explains how tickets work and what to include. + +--- + +## Before opening a ticket + +Check these resources first — your question may already be answered: + +- **Knowledge base**: Browse the guides in this docs site +- **Status page**: Check [status.arclineit.com](https://status.arclineit.com) for any active incidents or scheduled maintenance +- **FAQ**: Visit [arcline.it/faq](https://arcline.it/faq) for common questions + +--- + +## Opening a ticket + +### Via the customer portal (recommended) + +1. Log in to the [Arcline Customer Portal](https://portal.arclineit.com) +2. Click **Support → New Ticket** +3. Select the relevant department: + - **Billing** — invoices, plan changes, cancellations + - **Technical** — server issues, website problems, email setup + - **Migration** — moving from another provider to Arcline +4. Fill in the subject and description +5. Include as much detail as possible (see below) +6. Click **Submit** + +You'll receive a confirmation email with the ticket number. + +### Via email + +Send an email to **support@arclineit.com**. Include: + +- Your account username or email +- A clear subject line +- A detailed description of the issue +- Any relevant screenshots or error messages + +Your email will automatically create a ticket in the system and you'll receive a confirmation reply. + +--- + +## What to include in a ticket + +The more detail you provide, the faster we can help. Include: + +| Detail | Example | +|--------|---------| +| **Your domain** | `example.com` | +| **What you're trying to do** | "I'm trying to install WordPress on my VPS" | +| **What's happening** | "I get a 500 error when visiting example.com/wp-admin" | +| **What you've already tried** | "I've checked that PHP is running and the database credentials are correct" | +| **Relevant logs or errors** | Paste the exact error message, not a summary | +| **Recent changes** | "I updated WordPress to 6.7 yesterday" | + +--- + +## Response times + +| Priority | Response time | When to use | +|----------|--------------|-------------| +| **Critical** | < 1 hour | Site is down, email is not working, security issue | +| **High** | < 4 hours | Site is slow but accessible, email is delayed | +| **Normal** | < 24 hours | Configuration help, billing questions, migration support | +| **Low** | < 72 hours | Feature requests, general questions, feedback | + +Response times are based on Eastern Time (ET), Monday through Friday. Weekend and holiday responses may take longer. + +--- + +## Ticket statuses + +| Status | Meaning | +|--------|---------| +| **Open** | Received but not yet assigned | +| **In Progress** | Being worked on by a technician | +| **Awaiting Customer** | We need more information from you | +| **Resolved** | Issue has been resolved | +| **Closed** | Issue is complete and ticket is archived | + +If a ticket is marked **Awaiting Customer**, please respond as soon as possible. Tickets that remain in this status for more than 7 days will be automatically closed. + +--- + +## Providing SSH access + +If your issue requires investigation on the server, you may be asked to provide SSH access. You have two options: + +- **Temporary key**: Upload an SSH key and remove it after the issue is resolved +- **Add to known IPs**: We'll provide the IP we'll connect from — add it to your firewall's whitelist + +Never share your account password. Arcline staff will never ask for your password. + +--- + +## What's next + +- [Arcline nameservers and DNS records](/reference/nameservers/) +- [Supported PHP versions](/reference/php-versions/) +- [Resource limits by plan](/reference/resource-limits/) + diff --git a/content/vps/automated-backups.md b/content/vps/automated-backups.md new file mode 100644 index 0000000..c064e2f --- /dev/null +++ b/content/vps/automated-backups.md @@ -0,0 +1,253 @@ +--- +title: "Set Up Automated Backups with Restic" +description: "Automate encrypted off-site backups on your Arcline VPS using restic." +section: vps +order: 6 +--- + +# Set Up Automated Backups with Restic + +Restic is a fast, encrypted backup tool that supports local and remote storage backends (SFTP, S3, B2, rsync.net). This guide covers backing up your VPS to a remote repository. + +--- + +## Prerequisites + +- A VPS with sudo access +- A backup destination (SFTP server, Backblaze B2, or local storage) + +--- + +## Step 1 — Install restic + +```bash +sudo apt update +sudo apt install restic -y +``` + +Verify: + +```bash +restic version +``` + +--- + +## Step 2 — Initialize a repository + +### Option A: SFTP/SSH (recommended for Arcline customers) + +If you have SSH access to a backup server: + +```bash +restic init --repo sftp:backup@backup-server:/var/backups/example-vps/ +``` + +You'll be prompted for a repository password — this encrypts your backups. Store it in a password manager — if you lose it, you cannot recover your data. + +### Option B: Backblaze B2 + +```bash +export B2_ACCOUNT_ID="your-application-key-id" +export B2_ACCOUNT_KEY="your-application-key" +restic init --repo b2:bucket-name:/example-vps +``` + +### Option C: Local directory + +```bash +sudo mkdir -p /backups/example-vps-repo +restic init --repo /backups/example-vps-repo +``` + +--- + +## Step 3 — Create a backup script + +```bash +sudo nano /usr/local/bin/backup.sh +``` + +```bash +#!/bin/bash +set -e + +# Repository location and password +export RESTIC_REPOSITORY="sftp:backup@backup-server:/var/backups/example-vps/" +export RESTIC_PASSWORD="your-repo-password" + +# Files and directories to back up +BACKUP_PATHS=( + /var/www + /etc/nginx + /etc/letsencrypt + /opt + /home +) + +# Directories to exclude +EXCLUDE_PATTERNS=( + --exclude "/var/www/example.com/cache" + --exclude "*.log" +) + +echo "Starting backup at $(date)" + +# Create the backup +restic backup "${EXCLUDE_PATTERNS[@]}" "${BACKUP_PATHS[@]}" + +# Keep last 7 daily, 4 weekly, 6 monthly snapshots +restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune + +echo "Backup completed at $(date)" +``` + +Make it executable: + +```bash +sudo chmod +x /usr/local/bin/backup.sh +``` + +--- + +## Step 4 — Test the backup + +Run the backup manually: + +```bash +sudo /usr/local/bin/backup.sh +``` + +List snapshots: + +```bash +restic -r sftp:backup@backup-server:/var/backups/example-vps/ snapshots +``` + +> You'll need `RESTIC_PASSWORD` exported or passed via `--password-file` for any restic command. + +--- + +## Step 5 — Schedule daily backups with systemd + +Create a service file: + +```bash +sudo nano /etc/systemd/system/restic-backup.service +``` + +```ini +[Unit] +Description=Restic backup +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/backup.sh +``` + +Create a timer: + +```bash +sudo nano /etc/systemd/system/restic-backup.timer +``` + +```ini +[Unit] +Description=Daily restic backup + +[Timer] +OnCalendar=daily +RandomizedDelaySec=3600 +Persistent=true + +[Install] +WantedBy=timers.target +``` + +Enable and start the timer: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable restic-backup.timer +sudo systemctl start restic-backup.timer +``` + +Verify: + +```bash +sudo systemctl status restic-backup.timer +sudo systemctl list-timers | grep restic +``` + +--- + +## Step 6 — Restoring from a backup + +List available snapshots: + +```bash +restic -r sftp:backup@backup-server:/var/backups/example-vps/ snapshots +``` + +Restore the latest snapshot: + +```bash +restic -r sftp:backup@backup-server:/var/backups/example-vps/ restore latest --target /tmp/restore +``` + +Or restore a specific snapshot by ID: + +```bash +restic -r ... restore --target /tmp/restore +``` + +To restore only specific paths: + +```bash +restic -r ... restore --target /tmp/restore --path /var/www +``` + +--- + +## Step 7 — Database backups + +For MySQL databases, add a pre-backup dump step: + +```bash +#!/bin/bash +set -e + +# Dump all databases +mysqldump --all-databases --single-transaction --quick | gzip > /tmp/mysql-all.sql.gz + +# Include the dump in the backup +restic backup --hostname example-vps /tmp/mysql-all.sql.gz "${BACKUP_PATHS[@]}" + +rm /tmp/mysql-all.sql.gz +``` + +--- + +## Monitoring backups + +Add a health check notification: + +```bash +# After successful backup +curl -fsS -m 10 --retry 5 https://hc-ping.com/your-uuid + +# Or on failure, notify via Discord/Slack webhook +curl -fsS -m 10 -X POST -H "Content-Type: application/json" \ + -d '{"content":"Backup failed on example-vps"}' \ + https://discord.com/api/webhooks/your-webhook-url +``` + +--- + +## What's next + +- [Install fail2ban](/vps/fail2ban/) for SSH brute-force protection +- [Set up a Go service](/vps/go-systemd/) with systemd + diff --git a/content/vps/fail2ban.md b/content/vps/fail2ban.md new file mode 100644 index 0000000..e945350 --- /dev/null +++ b/content/vps/fail2ban.md @@ -0,0 +1,214 @@ +--- +title: "Set Up Fail2ban for SSH Brute-Force Protection" +description: "Protect your Arcline VPS from SSH brute-force attacks with fail2ban." +section: vps +order: 7 +--- + +# Set Up Fail2ban for SSH Brute-Force Protection + +Fail2ban monitors system logs for repeated failed login attempts and temporarily bans the offending IP addresses using the firewall. It's essential for any internet-facing server. + +--- + +## Prerequisites + +- A VPS with SSH access and sudo privileges +- UFW or iptables already installed (see [Initial VPS Setup](/vps/initial-setup/)) + +--- + +## Step 1 — Install fail2ban + +```bash +sudo apt update +sudo apt install fail2ban -y +``` + +--- + +## Step 2 — Configure fail2ban for SSH + +The default configuration file is `/etc/fail2ban/jail.conf`. Don't edit it directly — it gets overwritten on updates. Instead, create a local override: + +```bash +sudo nano /etc/fail2ban/jail.local +``` + +```ini +[DEFAULT] +# Ban IPs for 1 hour after 5 failed attempts within 10 minutes +bantime = 3600 +findtime = 600 +maxretry = 5 + +# Send email alerts (optional) +# destemail = you@example.com +# action = %(action_mwl)s + +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +``` + +If you changed your SSH port, specify it: + +```ini +[sshd] +enabled = true +port = 2222 +logpath = %(sshd_log)s +``` + +--- + +## Step 3 — Start fail2ban + +```bash +sudo systemctl enable fail2ban +sudo systemctl start fail2ban +``` + +Check the status: + +```bash +sudo systemctl status fail2ban +``` + +--- + +## Step 4 — Monitor banned IPs + +View the SSH jail status: + +```bash +sudo fail2ban-client status sshd +``` + +This shows the total bans and currently active bans. + +View the ban log: + +```bash +sudo tail -f /var/log/fail2ban.log +``` + +--- + +## Step 5 — Unban an IP + +If you accidentally lock yourself out (you should have tested SSH key access before enabling, but just in case): + +```bash +sudo fail2ban-client set sshd unbanip 203.0.113.42 +``` + +Or from the console (if you still have a root session open): + +```bash +sudo iptables -D f2b-sshd -s 203.0.113.42 -j DROP +``` + +--- + +## Step 6 — Additional jails (optional) + +### Nginx + +```ini +[nginx-http-auth] +enabled = true +logpath = /var/log/nginx/error.log +``` + +### Nginx bot protection (repeat offenders) + +```ini +[nginx-botsearch] +enabled = true +logpath = /var/log/nginx/access.log +maxretry = 2 +findtime = 86400 +bantime = 86400 +``` + +This bans IPs that hit common admin paths (wp-admin, etc.) that don't exist on your server. + +### Wordpress + +```ini +[wordpress] +enabled = true +filter = wordpress +logpath = /var/log/auth.log +``` + +You may need to create a custom filter for your specific application logs. + +--- + +## Step 7 — Whitelist IPs + +To exclude trusted IPs from bans (your office IP, for example): + +```ini +[DEFAULT] +ignoreip = 127.0.0.1/8 ::1 203.0.113.100 +``` + +--- + +## Permanent bans with recidive jail + +Habitual offenders get progressively longer bans: + +```ini +[recidive] +enabled = true +logpath = /var/log/fail2ban.log +maxretry = 3 +findtime = 604800 # 1 week +bantime = 604800 # 1 week +``` + +An IP that triggers bans 3 times in a week gets banned for a week. + +--- + +## Testing fail2ban + +From a different machine (or after whitelisting your IP), intentionally fail SSH login a few times: + +```bash +ssh nonexistent@your.vps.ip.address +``` + +After 5 failures, further attempts should hang or be refused. Check with: + +```bash +sudo fail2ban-client status sshd +``` + +--- + +## Performance notes + +Fail2ban uses minimal resources — typically under 50MB of RAM with a few jails enabled. It reads log files using Python's `pyinotify` (if available) or polls every second. + +If you have high-traffic sites with aggressive bots, increase `findtime` and lower `maxretry` to catch them sooner: + +```ini +[nginx-botsearch] +maxretry = 2 +findtime = 3600 +bantime = 86400 +``` + +--- + +## What's next + +- [Deploy a Go binary](/vps/go-systemd/) as a systemd service +- [Set up automated backups](/vps/automated-backups/) with restic + diff --git a/content/vps/go-systemd.md b/content/vps/go-systemd.md new file mode 100644 index 0000000..6a99343 --- /dev/null +++ b/content/vps/go-systemd.md @@ -0,0 +1,215 @@ +--- +title: "Deploy a Go Binary as a Systemd Service" +description: "Run a Go application as a background service on your Arcline VPS with systemd." +section: vps +order: 5 +--- + +# Deploy a Go Binary as a Systemd Service + +Go compiles to a single static binary — no runtime, no dependencies, no package manager. This makes it ideal for running as a systemd service on your Arcline VPS. + +--- + +## Prerequisites + +- A VPS with SSH access +- A Go binary compiled for Linux amd64 (or arm64 if using an ARM VPS) + +--- + +## Step 1 — Build your Go binary + +On your local machine, cross-compile for your target VPS: + +```bash +# For Linux amd64 (most common) +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o myapp + +# For Linux arm64 (e.g., Raspberry Pi) +GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o myapp +``` + +The `CGO_ENABLED=0` flag ensures a fully static binary with no external library dependencies. + +--- + +## Step 2 — Upload the binary + +```bash +scp myapp yourname@your.vps.ip.address:/tmp/ +``` + +On the VPS, move it to its final location: + +```bash +sudo mkdir -p /opt/myapp +sudo mv /tmp/myapp /opt/myapp/ +sudo chmod +x /opt/myapp/myapp +``` + +--- + +## Step 3 — Create a systemd service file + +```bash +sudo nano /etc/systemd/system/myapp.service +``` + +```ini +[Unit] +Description=My Go Application +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=yourname +Group=yourname +WorkingDirectory=/opt/myapp +ExecStart=/opt/myapp/myapp +Restart=always +RestartSec=5 +EnvironmentFile=-/opt/myapp/.env + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/myapp + +[Install] +WantedBy=multi-user.target +``` + +--- + +## Step 4 — Create an environment file + +If your app reads configuration from environment variables: + +```bash +sudo nano /opt/myapp/.env +``` + +``` +PORT=8080 +DATABASE_PATH=/opt/myapp/data.db +LOG_LEVEL=info +``` + +Secure the file: + +```bash +sudo chmod 600 /opt/myapp/.env +sudo chown yourname:yourname /opt/myapp/.env +``` + +--- + +## Step 5 — Start and enable the service + +```bash +sudo systemctl daemon-reload +sudo systemctl start myapp +sudo systemctl enable myapp # starts on boot +``` + +Check the status: + +```bash +sudo systemctl status myapp +``` + +--- + +## Step 6 — Set up Nginx reverse proxy (if it's a web app) + +If your Go app serves HTTP on a port like `8080`, put Nginx in front: + +```nginx +server { + listen 80; + server_name api.example.com; + + location / { + proxy_pass http://127.0.0.1:8080; + 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; + } +} +``` + +Enable SSL with Certbot: + +```bash +sudo certbot --nginx -d api.example.com +``` + +--- + +## Managing the service + +| Command | Description | +|---------|-------------| +| `sudo systemctl start myapp` | Start the service | +| `sudo systemctl stop myapp` | Stop the service | +| `sudo systemctl restart myapp` | Restart the service | +| `sudo systemctl status myapp` | Show status and recent logs | +| `sudo systemctl enable myapp` | Enable auto-start on boot | +| `sudo systemctl disable myapp` | Disable auto-start | +| `journalctl -u myapp -f` | Follow live logs | + +--- + +## Updating the binary + +```bash +# Build new version locally +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o myapp + +# Upload +scp myapp yourname@your.vps.ip.address:/tmp/ + +# On the VPS +sudo systemctl stop myapp +sudo cp /tmp/myapp /opt/myapp/ +sudo systemctl start myapp +sudo systemctl status myapp +``` + +--- + +## Logging + +Your Go app's stdout and stderr are automatically captured by systemd's journal. View them with: + +```bash +journalctl -u myapp -f +``` + +For persistent log files, your app can write to a file, or you can configure systemd to forward logs to syslog: + +```bash +sudo mkdir -p /var/log/myapp +sudo chown yourname:yourname /var/log/myapp +``` + +Then in your service file, add: + +``` +StandardOutput=append:/var/log/myapp/stdout.log +StandardError=append:/var/log/myapp/stderr.log +``` + +--- + +## What's next + +- [Set up automated backups](/vps/automated-backups/) with restic +- [Install fail2ban](/vps/fail2ban/) for SSH brute-force protection + diff --git a/content/vps/initial-setup.md b/content/vps/initial-setup.md new file mode 100644 index 0000000..558f06f --- /dev/null +++ b/content/vps/initial-setup.md @@ -0,0 +1,202 @@ +--- +title: "Initial VPS Setup (Debian/Ubuntu)" +description: "First steps after provisioning a new VPS: users, SSH keys, firewall, and system updates." +section: vps +order: 1 +--- + +# Initial VPS Setup + +This guide walks you through the first steps after provisioning a new Arcline VPS. You'll create a non-root user, harden SSH, set up a firewall, and apply system updates. + +--- + +## Before you begin + +You'll receive your VPS login credentials from Arcline after provisioning. Your initial login is as `root` via SSH. + +``` +ssh root@your.vps.ip.address +``` + +If you're on macOS or Linux, the SSH client is built in. On Windows, use PowerShell, Windows Terminal, or WSL. + +--- + +## Step 1 — Create a non-root user + +Working as root for daily tasks is risky. Create an administrative user: + +```bash +adduser yourname +``` + +Follow the prompts to set a strong password. Then add the user to the `sudo` group: + +```bash +usermod -aG sudo yourname +``` + +For Debian, the sudo group may be named differently. Verify with `groups yourname` — if you see `sudo`, you're set. + +--- + +## Step 2 — Copy your SSH key + +From your local machine (not the VPS), copy your SSH public key to the new user: + +```bash +ssh-copy-id yourname@your.vps.ip.address +``` + +If `ssh-copy-id` isn't available, manually create the `.ssh` directory and `authorized_keys` file: + +```bash +# On the VPS, as your new user: +mkdir -p ~/.ssh +chmod 700 ~/.ssh +# Edit this file and paste your public key +nano ~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +``` + +Test that key-based login works from a new terminal: + +```bash +ssh yourname@your.vps.ip.address +``` + +If you can log in without a password prompt, proceed. + +--- + +## Step 3 — Harden SSH + +Edit the SSH server configuration: + +```bash +sudo nano /etc/ssh/sshd_config +``` + +Make the following changes: + +``` +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +Port 22 +``` + +If you changed the SSH port, note it — you'll need it in firewall rules below. + +Restart SSH: + +```bash +sudo systemctl restart sshd +``` + +Before closing your current session, open a **second terminal** and verify you can still log in as your new user. If something went wrong, you still have the root session to fix it. + +--- + +## Step 4 — Set up the firewall (UFW) + +UFW (Uncomplicated Firewall) is the easiest way to manage iptables rules on Ubuntu/Debian. + +First, allow SSH so you don't lock yourself out: + +```bash +sudo ufw allow ssh +``` + +If you changed the SSH port: + +```bash +sudo ufw allow 2222/tcp # replace 2222 with your port +``` + +For a web server, allow HTTP and HTTPS: + +```bash +sudo ufw allow http +sudo ufw allow https +``` + +Enable the firewall: + +```bash +sudo ufw enable +``` + +Check the status: + +```bash +sudo ufw status verbose +``` + +Default deny on incoming, allow on outgoing is the correct policy. Only the ports you explicitly opened should be listed. + +--- + +## Step 5 — Apply system updates + +Keep the system current: + +```bash +sudo apt update +sudo apt upgrade -y +``` + +Enable automatic security updates: + +```bash +sudo apt install unattended-upgrades -y +sudo dpkg-reconfigure --priority=low unattended-upgrades +``` + +Select **Yes** when prompted about automatically installing security updates. + +--- + +## Step 6 — Set the timezone and hostname + +Set the correct timezone: + +```bash +sudo timedatectl set-timezone America/New_York # or your timezone +``` + +Verify with `timedatectl`. + +Set a descriptive hostname: + +```bash +sudo hostnamectl set-hostname myserver +``` + +Add it to `/etc/hosts`: + +```bash +echo "127.0.1.1 myserver" | sudo tee -a /etc/hosts +``` + +--- + +## Step 7 — Install essential tools + +A few packages you'll want on every server: + +```bash +sudo apt install -y curl wget git htop net-tools ufw fail2ban +``` + +Fail2ban will be configured in a dedicated guide. For now it runs with sensible defaults. + +--- + +## What's next + +- [Install Nginx + PHP-FPM + MySQL](/vps/nginx-php-mysql/) for a LEMP stack +- [Deploy a static site](/vps/static-site/) with Nginx +- [Set up fail2ban](/vps/fail2ban/) for SSH brute-force protection + diff --git a/content/vps/nginx-php-mysql.md b/content/vps/nginx-php-mysql.md new file mode 100644 index 0000000..fd5c68f --- /dev/null +++ b/content/vps/nginx-php-mysql.md @@ -0,0 +1,208 @@ +--- +title: "Install Nginx + PHP-FPM + MySQL on Debian/Ubuntu" +description: "Set up a LEMP stack on your Arcline VPS for hosting PHP applications and WordPress." +section: vps +order: 2 +--- + +# Install Nginx + PHP-FPM + MySQL + +This guide walks through setting up a LEMP stack (Linux, Nginx, MySQL, PHP) on your Arcline VPS. This is the foundation for hosting WordPress, Laravel, and most PHP applications. + +--- + +## Prerequisites + +- A VPS provisioned through Arcline +- SSH access with sudo privileges (see [Initial VPS Setup](/vps/initial-setup/)) +- A domain pointed to your VPS IP (see [Point Your Nameservers](/getting-started/nameservers/)) + +--- + +## Step 1 — Install Nginx + +Nginx is the web server. It's lightweight, fast, and handles concurrent connections much better than Apache. + +```bash +sudo apt update +sudo apt install nginx -y +``` + +Verify it's running: + +```bash +sudo systemctl status nginx +``` + +Visit your VPS IP in a browser — you should see the default Nginx welcome page. + +--- + +## Step 2 — Install MySQL (MariaDB) + +MariaDB is a drop-in replacement for MySQL that's faster and more open: + +```bash +sudo apt install mariadb-server mariadb-client -y +``` + +Run the security script: + +```bash +sudo mysql_secure_installation +``` + +Follow the prompts: +- Set a root password +- Remove anonymous users: **Y** +- Disallow root login remotely: **Y** +- Remove test database: **Y** +- Reload privilege tables: **Y** + +Verify the installation: + +```bash +sudo mysql -u root -p +``` + +You should get a MariaDB prompt. Type `exit` to quit. + +--- + +## Step 3 — Install PHP-FPM + +PHP-FPM (FastCGI Process Manager) runs PHP scripts. Install the version appropriate for your needs: + +```bash +# PHP 8.3 (recommended for most applications) +sudo apt install php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd php8.3-mbstring php8.3-xml php8.3-xmlrpc php8.3-zip php8.3-intl php8.3-bcmath -y +``` + +For WordPress, the required extensions are: `mysql`, `curl`, `gd`, `mbstring`, `xml`, `zip`. + +Verify PHP-FPM is running: + +```bash +sudo systemctl status php8.3-fpm +``` + +--- + +## Step 4 — Configure Nginx for PHP + +Create a site configuration file: + +```bash +sudo nano /etc/nginx/sites-available/example.com +``` + +Replace `example.com` with your actual domain: + +```nginx +server { + listen 80; + server_name example.com www.example.com; + root /var/www/example.com; + index index.php index.html; + + location / { + try_files $uri $uri/ =404; + } + + location ~ \.php$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; + } + + location ~ /\.ht { + deny all; + } +} +``` + +Create the web root and enable the site: + +```bash +sudo mkdir -p /var/www/example.com +sudo chown -R $USER:$USER /var/www/example.com +sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +--- + +## Step 5 — Test PHP processing + +Create a test file: + +```bash +echo " /var/www/example.com/info.php +``` + +Visit `http://example.com/info.php` in your browser — you should see the PHP information page. + +Remove this file after testing — it exposes sensitive server information: + +```bash +rm /var/www/example.com/info.php +``` + +--- + +## Step 6 — Set up SSL with Let's Encrypt + +Install Certbot: + +```bash +sudo apt install certbot python3-certbot-nginx -y +``` + +Obtain a certificate: + +```bash +sudo certbot --nginx -d example.com -d www.example.com +``` + +Follow the prompts. Certbot will automatically modify your Nginx config to serve HTTPS and set up auto-renewal. Verify the renewal timer: + +```bash +sudo systemctl status certbot.timer +``` + +--- + +## Directory structure summary + +``` +/var/www/example.com/ # Web root — your site files go here +├── index.php # Main entry point +├── wp-admin/ # (WordPress) admin panel +├── wp-content/ # (WordPress) themes, plugins, uploads +└── ... # Your application files + +/etc/nginx/ +├── sites-available/ # All site configs (enabled via symlink) +│ └── example.com +└── sites-enabled/ # Active site configs + └── example.com → ../sites-available/example.com +``` + +--- + +## Troubleshooting + +**Nginx fails to start:** Check syntax with `sudo nginx -t`. Look at the error log: `sudo journalctl -u nginx`. + +**PHP not processing:** Verify the PHP-FPM socket path matches in both your Nginx config and PHP-FPM pool config: `sudo nano /etc/php/8.3/fpm/pool.d/www.conf` — look for `listen =`. + +**MySQL connection refused:** Make sure MySQL is running: `sudo systemctl status mariadb`. Check the socket: `sudo mysql -u root -p -S /var/run/mysqld/mysqld.sock`. + +--- + +## What's next + +- [Install WordPress](/wordpress/install-vps/) on your VPS +- [Deploy a Node.js app](/vps/nodejs-pm2/) with PM2 +- [Set up automated backups](/vps/automated-backups/) with restic + diff --git a/content/vps/nodejs-pm2.md b/content/vps/nodejs-pm2.md new file mode 100644 index 0000000..56cab0e --- /dev/null +++ b/content/vps/nodejs-pm2.md @@ -0,0 +1,230 @@ +--- +title: "Deploy a Node.js App with PM2 and Nginx" +description: "Run a Node.js application behind Nginx reverse proxy with PM2 process management." +section: vps +order: 4 +--- + +# Deploy a Node.js App with PM2 and Nginx + +This guide covers running a Node.js application on your Arcline VPS with PM2 for process management and Nginx as a reverse proxy. + +--- + +## Prerequisites + +- A VPS with Nginx installed +- A Node.js application (Express, Koa, Fastify, or similar) +- SSH access to your VPS + +--- + +## Step 1 — Install Node.js + +Install Node.js from the official NodeSource repository (recommended over the system package manager): + +```bash +# Node.js 22.x (LTS) +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install nodejs -y +``` + +Verify: + +```bash +node --version +npm --version +``` + +--- + +## Step 2 — Install PM2 + +PM2 is a process manager that keeps your Node.js app running, handles logging, and provides monitoring. + +```bash +sudo npm install -g pm2 +``` + +--- + +## Step 3 — Upload your application + +Create a directory for your app: + +```bash +sudo mkdir -p /var/www/example.com +sudo chown -R $USER:$USER /var/www/example.com +``` + +Upload your application files via SFTP, rsync, or git: + +```bash +cd /var/www/example.com +git clone https://git.arcline.it/yourname/your-app.git . +npm install --production +``` + +--- + +## Step 4 — Start the app with PM2 + +```bash +pm2 start app.js --name example-app +``` + +Or if your app uses `npm start`: + +```bash +pm2 start npm --name example-app -- start +``` + +Save the PM2 process list so it restarts on reboot: + +```bash +pm2 save +pm2 startup +``` + +The `pm2 startup` command will output a command for you to run with sudo. Follow its instructions. + +--- + +## Step 5 — Configure Nginx as a reverse proxy + +Your Node.js app runs on a port like `3000` or `8080`. Nginx will sit in front of it, handling SSL and serving static assets directly. + +Create an Nginx config: + +```bash +sudo nano /etc/nginx/sites-available/example.com +``` + +```nginx +server { + listen 80; + server_name example.com www.example.com; + + # If your app serves static files from a public directory + location /static/ { + alias /var/www/example.com/public/; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection '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_cache_bypass $http_upgrade; + } +} +``` + +Enable the site: + +```bash +sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +Set up SSL: + +```bash +sudo certbot --nginx -d example.com -d www.example.com +``` + +--- + +## Step 6 — Environment variables + +Create an environment file: + +```bash +nano /var/www/example.com/.env +``` + +``` +PORT=3000 +NODE_ENV=production +DATABASE_URL=postgres://... +``` + +Update your PM2 process to load it: + +```bash +pm2 delete example-app +pm2 start app.js --name example-app --env-file /var/www/example.com/.env +pm2 save +``` + +Or use a PM2 ecosystem file (`ecosystem.config.js`): + +```javascript +module.exports = { + apps: [{ + name: 'example-app', + script: 'app.js', + env_file: '/var/www/example.com/.env', + instances: 2, + exec_mode: 'cluster', + max_memory_restart: '500M', + }] +}; +``` + +Then: + +```bash +pm2 start ecosystem.config.js +``` + +--- + +## PM2 useful commands + +| Command | Description | +|---------|-------------| +| `pm2 list` | List all processes | +| `pm2 logs` | Show live logs | +| `pm2 logs --lines 100` | Show last 100 lines | +| `pm2 monit` | Real-time CPU/memory monitor | +| `pm2 restart example-app` | Restart an app | +| `pm2 reload example-app` | Zero-downtime reload | +| `pm2 stop example-app` | Stop an app | +| `pm2 delete example-app` | Remove from PM2 | +| `pm2 save` | Save process list | +| `pm2 startup` | Generate startup script | + +--- + +## WebSocket support + +If your app uses WebSockets, ensure the upgrade headers are passed through. The config above already includes them. For socket.io, add these to your Nginx config: + +```nginx +location /socket.io/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400; +} +``` + +--- + +## What's next + +- [Deploy a Go binary](/vps/go-systemd/) as a systemd service +- [Set up automated backups](/vps/automated-backups/) with restic +- [Install fail2ban](/vps/fail2ban/) for SSH protection + diff --git a/content/vps/static-site.md b/content/vps/static-site.md new file mode 100644 index 0000000..ef04d6c --- /dev/null +++ b/content/vps/static-site.md @@ -0,0 +1,183 @@ +--- +title: "Deploy a Static Site with Nginx" +description: "Host a static HTML site, Hugo, or Jekyll site on your Arcline VPS with Nginx." +section: vps +order: 3 +--- + +# Deploy a Static Site with Nginx + +Static sites are fast, secure, and simple to host. This guide covers deploying plain HTML, Hugo, and Jekyll sites on your Arcline VPS. + +--- + +## Prerequisites + +- A VPS with Nginx installed (see [Nginx + PHP-FPM + MySQL](/vps/nginx-php-mysql/) for setup) +- A domain pointed to your VPS IP +- Your static site files (HTML, CSS, JS) ready to upload + +--- + +## Step 1 — Create the site directory + +```bash +sudo mkdir -p /var/www/example.com +sudo chown -R $USER:$USER /var/www/example.com +``` + +--- + +## Step 2 — Upload your site files + +### Via SFTP (FileZilla, Cyberduck) + +Connect to your VPS: + +``` +Host: your.vps.ip.address +User: yourname +Port: 22 +Protocol: SFTP +``` + +Upload files to `/var/www/example.com/`. + +### Via rsync (command line) + +```bash +rsync -avz --delete ./_site/ yourname@your.vps.ip.address:/var/www/example.com/ +``` + +The `--delete` flag removes remote files that no longer exist locally — perfect for rebuilds. + +### Via SCP + +```bash +scp -r ./my-site/* yourname@your.vps.ip.address:/var/www/example.com/ +``` + +--- + +## Step 3 — Configure Nginx + +Create a site configuration: + +```bash +sudo nano /etc/nginx/sites-available/example.com +``` + +```nginx +server { + listen 80; + server_name example.com www.example.com; + root /var/www/example.com; + index index.html; + + # Gzip static assets + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # HTML files — shorter cache + location ~* \.html$ { + expires 1h; + add_header Cache-Control "public, must-revalidate"; + } + + location / { + try_files $uri $uri/ =404; + } +} +``` + +Enable the site: + +```bash +sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +--- + +## Step 4 — Set up SSL + +```bash +sudo certbot --nginx -d example.com -d www.example.com +``` + +--- + +## Deploying a Hugo site + +If you build your site with Hugo locally: + +1. Generate the site: `hugo` +2. The output goes to the `public/` directory +3. Upload `public/` to your server + +Or **build directly on the VPS** (for CI/CD style deployment): + +```bash +# Install Hugo on the VPS +sudo apt install hugo -y +# Or download a specific version from GitHub releases + +# Clone your repo +git clone https://git.arcline.it/yourname/your-site.git /var/www/example.com-source + +# Build +cd /var/www/example.com-source +hugo -d /var/www/example.com +``` + +--- + +## Deploying a Jekyll site + +Jekyll requires Ruby. Install it on the VPS: + +```bash +sudo apt install ruby-full build-essential -y +sudo gem install jekyll bundler +``` + +Clone and build: + +```bash +git clone https://git.arcline.it/yourname/your-site.git /var/www/example.com-source +cd /var/www/example.com-source +bundle install +jekyll build -d /var/www/example.com +``` + +--- + +## Security headers for static sites + +Add this to your Nginx config inside the `server` block for recommended security headers: + +```nginx +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()"; +``` + +Test and reload: + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +--- + +## What's next + +- [Deploy a Node.js app](/vps/nodejs-pm2/) with PM2 + Nginx +- [Deploy a Go binary](/vps/go-systemd/) as a systemd service +- [Set up automated backups](/vps/automated-backups/) with restic + diff --git a/content/wordpress/install-shared.md b/content/wordpress/install-shared.md new file mode 100644 index 0000000..a1c1aeb --- /dev/null +++ b/content/wordpress/install-shared.md @@ -0,0 +1,164 @@ +--- +title: "Install WordPress on Shared Hosting" +description: "Install WordPress on Arcline shared hosting with cPanel — from one-click installers to manual setup and WordPress Toolkit." +section: wordpress +order: 1 +--- + +# Install WordPress on Shared Hosting + +WordPress can be installed on Arcline shared hosting in a few minutes. This covers the one-click installer (fastest), the manual method (for full control), and WordPress Toolkit (for managing multiple sites). + +--- + +## Option 1 — Softaculous one-click installer (recommended) + +Softaculous is included with every Arcline cPanel account. It installs WordPress with one click and lets you choose the install location, admin credentials, and all settings upfront. + +1. cPanel → **Software → WordPress Manager by Softaculous** +2. Click **Install** at the top +3. Fill in the form: + +| Field | What to enter | +|---|---| +| Choose Protocol | `https://` (recommended) or `https://www.` | +| Choose Domain | your domain from the dropdown | +| In Directory | leave blank (for the root of your domain) or enter a subdirectory like `blog` | +| Site Name | your site's title (can be changed later) | +| Site Description | short tagline (optional) | +| Admin Username | pick a unique username — **not** `admin` | +| Admin Password | use a strong password or click the key icon to generate one | +| Admin Email | your email address | + +Scroll down and click **Install**. WordPress is ready in under a minute. + +> Do not use `admin` as your username — it's the most targeted name for brute-force attacks. Pick something unique. + +--- + +## Option 2 — Manual install + +A manual install gives you full control over file placement, database setup, and the initial configuration. + +### Step 1 — Create a database + +1. cPanel → **Databases → MySQL Databases** +2. Under **Create New Database**, enter a name (e.g., `wp_yoursite`) and click **Create Database** +3. Under **Add New User**, create a user with a strong password +4. Under **Add User To Database**, select the user and database, then click **Add** +5. Check **All Privileges** and click **Make Changes** + +Write down the database name, username, and password. + +### Step 2 — Upload WordPress + +1. Download the latest WordPress `.zip` from [wordpress.org](https://wordpress.org/download/) +2. cPanel → **Files → File Manager** +3. Navigate to `public_html` (or the subdirectory where you want WordPress) +4. Click **Upload**, select the `.zip`, and wait for it to finish +5. In File Manager, select the uploaded `.zip` and click **Extract** +6. Move the extracted `wordpress/` folder contents into `public_html/` (or keep it in a subdirectory) +7. Delete the `.zip` file and the now-empty `wordpress/` folder + +### Step 3 — Run the installer + +1. Visit `https://yourdomain.com` in your browser +2. WordPress will detect no config file and show the setup screen +3. Click **Let's go!**, then enter your database details: + +| Field | Value | +|---|---| +| Database Name | the database you created in Step 1 | +| Username | the database user you created | +| Password | the database user's password | +| Database Host | `localhost` | +| Table Prefix | `wp_` (default is fine, or change it for extra security) | + +4. Click **Submit** → **Run the installation** +5. Fill in the site info: site title, admin username (not `admin`), password, and your email +6. Click **Install WordPress** + +--- + +## Option 3 — WordPress Toolkit (for multiple sites) + +WordPress Toolkit is included in cPanel and is ideal if you manage several WordPress sites on one account. + +1. cPanel → **Software → WordPress Toolkit** +2. Click **Install** +3. Choose your domain, directory, and basic settings +4. Click **Install** + +After installation, WordPress Toolkit lets you: +- Clone a site to a subdomain or another domain +- Create staging sites (copy production to a test area) +- Run WordPress, plugin, and theme updates from one screen +- Reset passwords and toggle debug mode without logging into WordPress + +--- + +## After installation + +### Enable SSL + +Your site should use HTTPS immediately. cPanel's AutoSSL will issue a certificate within a few minutes of installation. If it doesn't: + +1. cPanel → **Security → SSL/TLS Status** +2. Click **Run AutoSSL** +3. Wait a few minutes and refresh + +Once the certificate is issued, install **Really Simple SSL** in WordPress to automatically redirect HTTP to HTTPS and fix mixed content. + +### Update permalinks + +WordPress's default permalink structure (`?p=123`) is bad for SEO and usability. Change it immediately: + +1. WordPress admin → **Settings → Permalinks** +2. Choose **Post name** (the most common and SEO-friendly option) +3. Click **Save Changes** + +If you see a 404 error after switching permalinks, WordPress couldn't write to `.htaccess`. Copy the code WordPress shows at the bottom of the Permalinks page and paste it manually into `.htaccess` in File Manager. + +### Install essential plugins + +Start with these free plugins (install after you've confirmed the site works): + +- **Really Simple SSL** — handles HTTPS redirect and mixed content +- **Wordfence Security** — firewall, malware scanner, and login protection +- **UpdraftPlus** — automated backups to remote storage (Google Drive, Dropbox, etc.) + +### Set up automated backups + +Don't rely on manual backups. Configure UpdraftPlus to run daily backups to off-server storage. See [Back Up and Restore a MySQL Database](/getting-started/mysql-backup/) for database backup details. + +--- + +## Installing in a subdirectory + +If you want WordPress at `https://yourdomain.com/blog/` rather than the root of your domain: + +1. In Softaculous or during manual setup, enter `blog` as the directory +2. WordPress files go into `public_html/blog/` +3. Your homepage at `yourdomain.com` can be a static site or an HTML landing page + +This is a common setup when your main site is not WordPress (e.g., a static business site with a separate blog). + +--- + +## Common setup issues + +**Error establishing a database connection** — the database credentials in `wp-config.php` don't match what you created in cPanel. Double-check the database name, username, and password. Note that all three are prefixed with your cPanel username (e.g., `cpaneluser_wp_yoursite`). + +**White screen after install** — a PHP error. Enable debugging temporarily by adding this to `wp-config.php`: + +```php +define( 'WP_DEBUG', true ); +define( 'WP_DEBUG_LOG', true ); +``` + +Check `wp-content/debug.log` for the specific error. Remove or set to `false` after debugging. + +**Can't upload files** — the `wp-content/uploads/` directory may have incorrect permissions. In File Manager, right-click the `uploads` folder and set permissions to **755**. If that doesn't fix it, try **775**. + +**404 on all pages except home** — permalink rules aren't being applied. Go to Settings → Permalinks and click **Save Changes** twice (this forces WordPress to regenerate `.htaccess` rules). If it still doesn't work, check that your `.htaccess` file exists in `public_html/` and is writable. + diff --git a/content/wordpress/install-vps.md b/content/wordpress/install-vps.md new file mode 100644 index 0000000..949d225 --- /dev/null +++ b/content/wordpress/install-vps.md @@ -0,0 +1,341 @@ +--- +title: "Install WordPress on a VPS" +description: "Set up WordPress on your Arcline VPS from scratch — LAMP or LEMP stack with PHP, MySQL, and Nginx or Apache." +section: wordpress +order: 2 +--- + +# Install WordPress on a VPS + +Installing WordPress on a VPS gives you full control over the server configuration, performance tuning, and security. This guide covers both a LEMP stack (Linux, Nginx, MySQL, PHP-FPM) and a LAMP stack (Linux, Apache, MySQL, PHP). + +If you haven't set up your VPS yet, start with [Initial VPS Setup](/vps/initial-setup/). + +--- + +## Choose your stack + +| Stack | Web Server | Best for | +|---|---|---| +| **LEMP** | Nginx + PHP-FPM | High traffic, static caching, modern setups | +| **LAMP** | Apache + PHP | Simpler `.htaccess` support, beginner-friendly | + +This guide covers the LEMP stack (Nginx) as the primary setup with LAMP (Apache) notes where they differ. + +--- + +## Step 1 — Install the stack + +### LEMP (Nginx) + +```bash +sudo apt update +sudo apt install -y nginx mysql-server php-fpm php-mysql php-curl php-gd \ + php-mbstring php-xml php-zip php-intl php-imagick unzip curl +``` + +### LAMP (Apache) + +```bash +sudo apt update +sudo apt install -y apache2 mysql-server php libapache2-mod-php php-mysql \ + php-curl php-gd php-mbstring php-xml php-zip php-intl php-imagick unzip curl +``` + +### Secure MySQL and create the database + +```bash +sudo mysql_secure_installation +``` + +Follow the prompts — set a root password, remove anonymous users, disallow remote root login, remove test databases, and reload privileges. + +Now create the WordPress database and user: + +```bash +sudo mysql -u root -p +``` + +```sql +CREATE DATABASE wordpress CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'wpuser'@'localhost' IDENTIFIED BY 'a-strong-password-here'; +GRANT ALL PRIVILEGES ON wordpress.* TO 'wpuser'@'localhost'; +FLUSH PRIVILEGES; +EXIT; +``` + +Replace `a-strong-password-here` with an actual strong password. + +--- + +## Step 2 — Download and set up WordPress + +```bash +cd /tmp +curl -O https://wordpress.org/latest.tar.gz +tar xzf latest.tar.gz +sudo mv wordpress /var/www/yourdomain.com +sudo chown -R www-data:www-data /var/www/yourdomain.com +``` + +--- + +## Step 3 — Configure Nginx (LEMP) + +Create the Nginx site configuration: + +```bash +sudo nano /etc/nginx/sites-available/yourdomain.com +``` + +```nginx +server { + listen 80; + server_name yourdomain.com www.yourdomain.com; + root /var/www/yourdomain.com; + index index.php index.html; + + location / { + try_files $uri $uri/ /index.php?$args; + } + + location ~ \.php$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + # Block access to sensitive files + location ~* /\.(?!well-known\/) { deny all; } + location ~* /wp-config\.php { deny all; } + location ~* /xmlrpc\.php { deny all; } + + # Cache static assets in the browser + location ~* \.(css|js|ico|gif|jpg|jpeg|png|webp|svg|woff2?|ttf|otf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} +``` + +Enable the site and test the config: + +```bash +sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +Replace `php8.1-fpm.sock` with the PHP version you installed. Check your PHP version: + +```bash +php -v +``` + +--- + +## Step 3 (alt) — Configure Apache (LAMP) + +```bash +sudo nano /etc/apache2/sites-available/yourdomain.com.conf +``` + +```apache + + ServerName yourdomain.com + ServerAlias www.yourdomain.com + DocumentRoot /var/www/yourdomain.com + + + AllowOverride All + Require all granted + + +``` + +Enable and restart: + +```bash +sudo a2ensite yourdomain.com.conf +sudo a2enmod rewrite +sudo systemctl reload apache2 +``` + +--- + +## Step 4 — Set up SSL with Let's Encrypt + +```bash +sudo apt install -y certbot python3-certbot-nginx +sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com +``` + +For Apache, use `python3-certbot-apache` instead. + +Certbot modifies your Nginx/Apache config to add SSL automatically. Certificates renew automatically via a systemd timer — verify with: + +```bash +sudo certbot renew --dry-run +``` + +--- + +## Step 5 — Complete the WordPress install + +Visit `https://yourdomain.com` in your browser and complete the WordPress setup wizard. When prompted for database credentials, enter: + +| Field | Value | +|---|---| +| Database Name | `wordpress` | +| Username | `wpuser` | +| Password | the password you set in Step 1 | +| Database Host | `localhost` | +| Table Prefix | `wp_` | + +--- + +## Step 6 — Configure file permissions + +WordPress needs write access to `wp-content/uploads` (for media) but the rest of your install should be read-only for the web server to prevent tampering. + +```bash +sudo chown -R www-data:www-data /var/www/yourdomain.com +sudo find /var/www/yourdomain.com -type d -exec chmod 755 {} \; +sudo find /var/www/yourdomain.com -type f -exec chmod 644 {} \; +sudo chmod 640 /var/www/yourdomain.com/wp-config.php +``` + +--- + +## Step 7 — Configure PHP-FPM for WordPress + +Edit the PHP-FPM pool config: + +```bash +sudo nano /etc/php/8.1/fpm/pool.d/www.conf +``` + +Increase resource limits for a production WordPress site: + +```ini +pm = dynamic +pm.max_children = 20 +pm.start_servers = 5 +pm.min_spare_servers = 5 +pm.max_spare_servers = 10 +pm.max_requests = 500 +``` + +Adjust `pm.max_children` based on your VPS RAM: roughly `(available_RAM - 512MB) / 50MB` per child. + +Apply changes: + +```bash +sudo systemctl restart php8.1-fpm +``` + +--- + +## Step 8 — Set up a firewall + +```bash +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +For stricter security, see [Set up fail2ban](/vps/fail2ban/). + +--- + +## Step 9 — Configure WordPress cron properly + +WordPress's default pseudo-cron runs on every page load, which is wasteful on a VPS. Replace it with a real system cron job: + +```bash +sudo crontab -u www-data -e +``` + +Add: + +``` +*/5 * * * * /usr/bin/php /var/www/yourdomain.com/wp-cron.php > /dev/null 2>&1 +``` + +Then disable WordPress pseudo-cron in `wp-config.php`: + +```php +define( 'DISABLE_WP_CRON', true ); +``` + +--- + +## Performance tuning + +### PHP opcache + +Uncomment and tweak in `/etc/php/8.1/fpm/php.ini`: + +```ini +opcache.enable=1 +opcache.memory_consumption=256 +opcache.interned_strings_buffer=16 +opcache.max_accelerated_files=10000 +opcache.revalidate_freq=2 +opcache.fast_shutdown=1 +``` + +### MySQL tuning + +For a 2 GB VPS, add to `/etc/mysql/mysql.conf.d/mysqld.cnf`: + +```ini +innodb_buffer_pool_size = 512M +innodb_log_file_size = 128M +query_cache_type = 0 +``` + +Run MySQLTuner after a few days of uptime for more specific recommendations: + +```bash +sudo apt install mysqltuner +sudo mysqltuner +``` + +### WordPress object caching with Redis + +If your VPS has enough RAM (2 GB+ free), Redis dramatically speeds up WordPress: + +```bash +sudo apt install redis-server +sudo systemctl enable redis-server +``` + +Install the **Redis Object Cache** plugin in WordPress admin and click **Enable Object Cache**. + +--- + +## Automating updates + +Set up unattended security updates for the OS: + +```bash +sudo apt install unattended-upgrades +sudo dpkg-reconfigure unattended-upgrades +``` + +WordPress core auto-updates are enabled by default for minor versions. For plugins and themes, enable auto-updates in WordPress admin → Plugins → toggle **Enable auto-updates** on each plugin you trust. + +--- + +## Next steps + +- [Configure W3 Total Cache](/wordpress/w3-total-cache/) for page caching and performance +- [WordPress security hardening](/wordpress/security/) — tighten file permissions, disable XML-RPC, and set up login protection +- [Automated backups with restic](/vps/restic-backups/) for off-site backups of your files and database + diff --git a/content/wordpress/security.md b/content/wordpress/security.md new file mode 100644 index 0000000..442009f --- /dev/null +++ b/content/wordpress/security.md @@ -0,0 +1,203 @@ +--- +title: "WordPress Security Hardening" +description: "Lock down your WordPress site on Arcline — file permissions, login protection, XML-RPC hardening, and no third-party CDN required." +section: wordpress +order: 4 +--- + +# WordPress Security Hardening + +Most WordPress compromises happen through outdated plugins or weak passwords — not through server vulnerabilities. These steps harden a standard WordPress install on Arcline against the most common attacks. + +--- + +## Keep everything updated + +The single most effective security measure is updating WordPress core, plugins, and themes promptly. Every Arcline cPanel account includes: + +- **WordPress Toolkit** (cPanel → Software) — shows update status for all your WordPress sites at a glance. Click **Update** to apply security patches across all sites in one go. +- **Softaculous** — can auto-update WordPress core. Go to Softaculous → **WordPress Manager → Settings** and enable auto-updates. + +In WordPress admin, enable auto-updates for plugins and themes you trust. Go to **Plugins → Installed Plugins** and click **Enable auto-updates** next to each plugin. + +--- + +## File permissions + +WordPress files should be readable by the web server but not writable by anyone other than your cPanel user. Incorrect permissions are the most common way an attacker who gains access through a plugin vulnerability escalates to full site takeover. + +Via cPanel **File Manager** or SFTP: + +- **Directories:** `755` (rwxr-xr-x) +- **Files:** `644` (rw-r--r--) +- **wp-config.php:** `640` or `600` — the most sensitive file in your install +- **wp-content/uploads/:** `755` — must be writable for media uploads + +To fix permissions via SSH: + +```bash +find /home/username/public_html -type d -exec chmod 755 {} \; +find /home/username/public_html -type f -exec chmod 644 {} \; +chmod 640 /home/username/public_html/wp-config.php +``` + +Run these as your cPanel user — not as root. + +--- + +## Block XML-RPC + +XML-RPC is a legacy API that's almost never needed by modern WordPress sites. It's heavily abused for brute-force attacks and DDoS amplification. Most sites can disable it entirely. + +**Via .htaccess** (shared hosting): + +```apache + + Order Deny,Allow + Deny from all + +``` + +**Via Nginx** (VPS) — add to your site config: + +```nginx +location = /xmlrpc.php { deny all; } +``` + +**Plugins that need XML-RPC** (don't disable it if you use these): +- Jetpack (some features) +- The WordPress mobile app +- Trackbacks and pingbacks (disabled anyway on most sites) + +If you use the WordPress mobile app, you need XML-RPC. For everyone else, disabling it has no downside. + +--- + +## Protect wp-config.php + +`wp-config.php` contains your database credentials. Anyone who reads this file owns your database. + +**.htaccess** protection (add at the top of `.htaccess`): + +```apache + + Order Deny,Allow + Deny from all + +``` + +For defense in depth, move `wp-config.php` one directory **above** `public_html` — WordPress looks there automatically. If it's currently at `/home/username/public_html/wp-config.php`, move it to `/home/username/wp-config.php`. WordPress will find it. + +--- + +## Disable file editing from the admin panel + +By default, any WordPress administrator can edit theme and plugin files directly from the admin panel. If an attacker compromises an admin account, this lets them inject arbitrary PHP code and take over the entire server. + +Add to `wp-config.php`: + +```php +define( 'DISALLOW_FILE_EDIT', true ); +``` + +This removes the **Appearance → Theme File Editor** and **Plugins → Plugin File Editor** menu items for everyone. You'll make file changes via SFTP or cPanel File Manager instead. + +--- + +## Disable plugin and theme installation from the admin panel (advanced) + +On a production site where you manage installations through SFTP, you can completely disable the ability to install plugins and themes from the admin panel: + +```php +define( 'DISALLOW_FILE_MODS', true ); +``` + +This blocks plugin/theme installs, updates, and deletions from the WordPress admin. Updates must be done via WP-CLI, WordPress Toolkit, or manually via SFTP. This is aggressive but very effective — it's a trade-off between convenience and security. + +--- + +## Limit login attempts + +WordPress has no built-in rate limiting on the login page, so attackers can try thousands of passwords without restriction. + +Install **Wordfence Security** (free) or **Limit Login Attempts Reloaded**. Both block IPs after a configurable number of failed attempts. + +**Wordfence settings:** +- **Wordfence → Firewall → Brute Force Protection** +- Set **Lock out after how many login failures** to `5` +- Set **Lock out after how many forgot password attempts** to `5` +- Set **Amount of time a user is locked out** to `1 hour` + +Wordfence also includes a web application firewall (WAF) that blocks common WordPress attacks before they reach your site — enable it from the Wordfence dashboard. + +--- + +## Use strong authentication + +**Strong passwords** — use the password generator built into WordPress. A password like `myfavoritecat` is trivial to crack; a random one like `8*kF$2nP!xq` is effectively unbreakable. + +**Two-factor authentication (2FA)** — install **Wordfence Login Security** (free, from the same developer as Wordfence Security) or **Two Factor** (official WordPress plugin). Both support TOTP (Google Authenticator, Authy, etc.) and backup codes. + +**Change the default admin username** — never use `admin`, `administrator`, `root`, or your domain name as the admin username. If you already have an `admin` user, create a new administrator account with a unique username, log in with it, and delete the old `admin` account. + +--- + +## Hide WordPress version + +Every WordPress install outputs its version number by default, making it easy for attackers to target known vulnerabilities. Remove it with your security plugin (Wordfence → All Options → **Hide WordPress version**) or by adding a filter: + +```php +remove_action( 'wp_head', 'wp_generator' ); +``` + +--- + +## Disable directory listing + +If someone visits `https://yourdomain.com/wp-content/uploads/` directly, they should see a blank page or redirect — not a list of every file in the directory. + +Add to `.htaccess`: + +```apache +Options -Indexes +``` + +Arcline shared hosting has this enabled by default. Verify by visiting `https://yourdomain.com/wp-includes/` in your browser — you should see a 403 Forbidden, not a file list. + +--- + +## Change the database table prefix + +The default WordPress table prefix is `wp_`. Changing it to something random makes SQL injection attacks harder — the attacker has to guess your table names. + +**For new installs:** change the prefix during installation when WordPress asks for it. + +**For existing sites:** use the **Brozzme DB Prefix** plugin or do it manually (requires editing `wp-config.php` and renaming all database tables — not recommended unless you're comfortable with MySQL). + +--- + +## Disable unused user enumeration + +By default, visiting `https://yourdomain.com/?author=1` reveals the admin username in the URL or redirect. Attackers use this to collect usernames for brute-force attacks. + +Block it with Wordfence (enabled by default) or add to your theme's `functions.php`: + +```php +if ( ! is_admin() && isset( $_SERVER['QUERY_STRING'] ) ) { + if ( preg_match( '/author=([0-9]*)/', $_SERVER['QUERY_STRING'] ) ) { + wp_redirect( home_url() ); + exit; + } +} +``` + +--- + +## Backup before you harden + +Some security changes can break things. Before making any significant changes: + +1. Take a full cPanel backup (cPanel → **Files → Backup**) +2. Export your database separately (see [Back Up and Restore a MySQL Database](/getting-started/mysql-backup/)) +3. Test changes one at a time so you know which one caused a problem if something breaks + diff --git a/content/wordpress/w3-total-cache.md b/content/wordpress/w3-total-cache.md new file mode 100644 index 0000000..1f239a1 --- /dev/null +++ b/content/wordpress/w3-total-cache.md @@ -0,0 +1,196 @@ +--- +title: "Configure W3 Total Cache Without a CDN" +description: "Speed up your WordPress site with W3 Total Cache on Arcline — page caching, browser caching, and opcode caching without third-party CDNs." +section: wordpress +order: 3 +--- + +# Configure W3 Total Cache Without a CDN + +W3 Total Cache (W3TC) is a free WordPress caching plugin that speeds up your site by storing pre-rendered pages, compressing assets, and leveraging browser caching. This guide configures W3TC for good performance **without** a third-party CDN — everything runs on your Arcline server. + +--- + +## Why no CDN? + +CDNs add a third party between your visitors and your server. You may not need one: + +- Arcline servers are fast and colocated in well-connected data centers +- A properly cached WordPress site on Arcline loads in under a second for most visitors +- CDNs introduce an additional cost, privacy concern, and point of failure + +If you do want CDN coverage later, W3TC supports Cloudflare, BunnyCDN, and generic pull CDNs — but you don't need one to get started. + +--- + +## Install W3 Total Cache + +1. WordPress admin → **Plugins → Add New** +2. Search for "W3 Total Cache" +3. Click **Install Now** → **Activate** + +The plugin adds a **Performance** menu to the sidebar. All configuration lives there. + +--- + +## Page cache (most important) + +Page caching saves fully rendered HTML pages so WordPress doesn't process PHP and query the database for every request. A cached page is served in milliseconds. + +1. **Performance → General Settings** +2. Under **Page Cache**, check **Enable** +3. Set **Page Cache Method** to **Disk: Enhanced** +4. Click **Save all settings** + +**Disk: Enhanced** writes static `.html` files to the cache directory and serves them directly via `.htaccess` or Nginx rules — WordPress isn't even loaded for cached pages. This is the fastest option that doesn't require extra server software. + +### Verify it's working + +Visit your site in an incognito window (so you're not logged in as admin) and view the page source. Scroll to the bottom — you should see: + +```html + +``` + +--- + +## Browser cache + +Browser caching tells visitors' browsers to store images, CSS, and JavaScript files locally so they don't re-download on every page view. + +1. **Performance → General Settings** +2. Under **Browser Cache**, check **Enable** +3. Click **Save all settings** + +Then configure each section: + +**Performance → Browser Cache → CSS & JS:** +- Set **Expires header lifetime** to `31536000` seconds (1 year) +- Check **Set cache control header** +- Set **Cache Control policy** to `cache with max-age` + +**Performance → Browser Cache → HTML & XML:** +- Set **Expires header lifetime** to `3600` seconds (1 hour) +- Check **Set cache control header** +- Set **Cache Control policy** to `cache with max-age` + +**Performance → Browser Cache → Media & Other Files:** +- Set **Expires header lifetime** to `31536000` seconds (1 year) +- **Cache Control policy** to `cache with max-age` + +Click **Save all settings** after each tab. + +--- + +## Minify (optional — test carefully) + +Minification reduces file sizes by stripping whitespace and comments from HTML, CSS, and JS. It can improve load times but also **can break your site** if not configured correctly. + +Start with HTML minification only — it's the safest: + +1. **Performance → General Settings → Minify → Enable** +2. Set **Minify mode** to **Manual** (not Auto) +3. Click **Save all settings** + +Then in **Performance → Minify → HTML & XML:** +- Check **Enable** for HTML minify +- Leave JS and CSS minify disabled for now +- Click **Save all settings** + +Test your site thoroughly. If anything looks wrong, disable minification for that type and try a different combination. + +**If your theme CSS or JS files have incorrect paths after minifying**, you may need to add them to the "Never minify" list in the JS or CSS settings tab. + +--- + +## Object cache + +Object caching stores database query results in memory, reducing repeated database queries. On Arcline shared hosting, use **Disk** as the caching method: + +1. **Performance → General Settings → Object Cache → Enable** +2. Set **Object Cache Method** to **Disk** +3. Click **Save all settings** + +On a VPS with Redis installed, set the method to **Redis** instead and enter `127.0.0.1:6379` as the server. Redis object caching is significantly faster than disk-based caching. + +--- + +## Database cache (use on VPS only) + +Database caching stores query results. On shared hosting, it can slow things down if the disk is under load. On a VPS, it helps: + +1. **Performance → General Settings → Database Cache → Enable** +2. Set method to **Disk** (or **Redis** on a VPS with Redis) +3. Click **Save all settings** + +Skip this on shared hosting unless your site has heavy database usage (WooCommerce, membership sites, forums). + +--- + +## Exclude pages from caching + +Some pages should never be cached: + +- **Cart, checkout, and account pages** (for WooCommerce) +- **Login and registration pages** +- **Admin pages** + +**Performance → Page Cache → Advanced:** + +In the **"Never cache the following pages"** field, add: + +``` +wp-login.php +wp-admin/* +cart/* +checkout/* +my-account/* +``` + +For WooCommerce specifically, W3TC should detect it and add these automatically. If not, add `/cart/`, `/checkout/`, `/my-account/` individually — one per line. + +--- + +## Clear the cache + +You'll need to clear the cache whenever you make significant site changes (new theme, updated plugins, content restructuring): + +- **Performance → Dashboard → Empty all caches** +- Or use the admin bar: **Performance → Purge All Caches** + +Set up automatic purging for new posts: + +- **Performance → Page Cache → Purge Policy:** +- Check **Front page**, **Posts page**, and **Post page** + +This keeps your cache fresh without manual intervention after publishing new content. + +--- + +## Testing your cache setup + +After configuring, test your site's performance: + +1. Visit your site in an incognito window +2. Open browser DevTools → Network tab +3. Reload the page and check: + - **HTML document:** should load in under 200 ms + - **CSS/JS files:** should show "304 Not Modified" or "(disk cache)" on second load + - **Images:** similar — cached after the first load + +For more detailed testing, use [PageSpeed Insights](https://pagespeed.web.dev) or [GTmetrix](https://gtmetrix.com). Both give specific recommendations for improvement. + +--- + +## Troubleshooting + +**Site looks broken after enabling minify** — disable minification for the type that broke (JS, CSS, or HTML). Minify is the most likely setting to cause issues. + +**Logged-in users see stale pages** — W3TC should skip caching for logged-in users by default. Check **Performance → Page Cache → Advanced → "Don't cache pages for logged in users"** is checked. + +**Cache files filling up disk space** — W3TC has garbage collection that runs on WordPress cron. On a busy site, the cache directory can grow. Set a reasonable **Garbage collection interval** in **Performance → Page Cache → Advanced** (the default 3600 seconds / 1 hour is fine). + +**CDN tab references** — ignore everything in the **CDN** settings section. That's for Cloudflare, BunnyCDN, or generic pull CDNs, which this guide intentionally avoids. + +**"Disk: Enhanced" not available** — your server may not support the enhanced mode. Switch to **Disk: Basic** instead, which uses PHP to serve cached pages. It's slightly slower but works everywhere. + diff --git a/content/wordpress/woocommerce.md b/content/wordpress/woocommerce.md new file mode 100644 index 0000000..4a8bbfb --- /dev/null +++ b/content/wordpress/woocommerce.md @@ -0,0 +1,181 @@ +--- +title: "Setting Up WooCommerce on Arcline" +description: "Install and configure WooCommerce on your Arcline VPS or shared hosting plan." +section: wordpress +order: 5 +--- + +# Setting Up WooCommerce on Arcline + +WooCommerce is the most popular e-commerce platform for WordPress. This guide covers installing it on Arcline shared hosting or a VPS and optimizing it for performance without a CDN. + +--- + +## Prerequisites + +- WordPress installed and running (see [Install WordPress](/wordpress/install-shared/) for shared hosting or [Install WordPress on a VPS](/wordpress/install-vps/) for VPS) +- A domain pointed to your Arcline server +- SSL certificate installed (Let's Encrypt via cPanel or Certbot) +- PHP memory limit of at least 256 MB (512 MB recommended for VPS) + +--- + +## Step 1 — Install WooCommerce + +### Via the WordPress admin + +1. Log in to your WordPress admin dashboard at `https://yourdomain.com/wp-admin` +2. Go to **Plugins → Add New** +3. Search for "WooCommerce" +4. Click **Install Now** → **Activate** + +WooCommerce will launch the setup wizard on activation. + +### Via WP-CLI (faster, especially on a VPS) + +```bash +# Install and activate WooCommerce +wp plugin install woocommerce --activate + +# Install a recommended theme (e.g., Storefront) +# wp theme install storefront --activate +``` + +--- + +## Step 2 — Run the setup wizard + +The WooCommerce setup wizard will walk you through: + +1. **Store location**: Set your business address, currency (USD), and selling location +2. **Industry**: What types of products you sell (physical, digital, membership, etc.) +3. **Product types**: Simple products, variable products, external/affiliate products +4. **Business details**: Whether you're already selling elsewhere +5. **Payment methods**: WooPayments, PayPal, Stripe, or offline payments +6. **Shipping**: Set up shipping zones and rates +7. **Tax**: Configure basic tax settings +8. **Personalize**: Choose a theme and install free extensions + +You can skip any step and configure it later. + +--- + +## Step 3 — Choose payment methods + +### WooPayments (built-in, recommended) + +WooPayments is Stripe-based and included with WooCommerce. It accepts credit cards, Apple Pay, and Google Pay. No additional plugin is needed. + +To set it up: +1. Go to **WooCommerce → Settings → Payments** +2. Click **Set up** next to WooPayments +3. Follow the prompts to connect your Stripe account + +### PayPal + +1. Go to **WooCommerce → Settings → Payments** +2. Toggle **PayPal** on +3. Click **Set up** and enter your PayPal email address + +### Stripe (standalone plugin) + +If you prefer the standalone Stripe plugin instead of WooPayments: + +```bash +wp plugin install woocommerce-gateway-stripe --activate +``` + +Then configure it in **WooCommerce → Settings → Payments → Stripe**. + +--- + +## Step 4 — Configure shipping + +1. Go to **WooCommerce → Settings → Shipping** +2. Click **Add shipping zone** +3. Name the zone (e.g., "United States") +4. Select the zone regions (countries, states, or postcode ranges) +5. Click **Add shipping method**: + - **Flat rate** — Single rate per order (e.g., $5.99) + - **Free shipping** — Free shipping with a minimum order amount + - **Local pickup** — Customer picks up at your location + +--- + +## Step 5 — Performance optimization for WooCommerce + +WooCommerce adds database queries and page weight. Optimize it carefully: + +### Essential caching + +1. Install a caching plugin. See [W3 Total Cache Configuration](/wordpress/w3-total-cache/) for detailed setup. +2. **Important**: In W3 Total Cache, do NOT enable page caching for the cart, checkout, and my-account pages. Add these to the **Never cache the following pages** list: `/cart/*`, `/checkout/*`, `/my-account/*`, `/wc-api/*` + +### Disable unused features + +Go to **WooCommerce → Settings → Advanced → Features** and disable: +- Coupons (if you don't use them) +- If digital only, disable shipping and tax + +### Enable native cart fragments (if needed) + +Cart fragments (the little cart icon that updates via AJAX) are expensive on shared hosting. If your theme doesn't need real-time cart updates, disable it: + +```bash +wp option set woocommerce_cart_fragments_enabled no +``` + +Customers will see a non-JS fallback link to the cart page. + +### Image optimization + +- Product images should be no larger than 1200px on the longest side +- Use WebP format for product images (convert with `cwebp`) +- Install a plugin like **Smush** or **Imagify** for automatic compression +- Set WooCommerce image sizes appropriately: **WooCommerce → Settings → Products → Display** + +--- + +## Step 6 — Essential WooCommerce plugins + +| Plugin | Purpose | +|--------|---------| +| [WooCommerce](https://wordpress.org/plugins/woocommerce/) | Core e-commerce platform | +| [Akismet Anti-Spam](https://wordpress.org/plugins/akismet/) | Block spam product reviews | +| [W3 Total Cache](https://wordpress.org/plugins/w3-total-cache/) | Page cache, DB cache, object cache | +| [UpdraftPlus](https://wordpress.org/plugins/updraftplus/) | Scheduled backups of your store | +| [WooCommerce Stripe](https://wordpress.org/plugins/woocommerce-gateway-stripe/) | Stripe credit card payments | + +--- + +## Step 7 — SSL and security + +- **WooCommerce forces SSL on checkout** automatically. Ensure your SSL certificate is valid. +- **Force HTTPS for the entire site** in W3 Total Cache → General Settings → Page Cache → Enable HTTP(S) support +- **Disable file editing** in WordPress admin by adding to `wp-config.php`: + ```php + define('DISALLOW_FILE_EDIT', true); + ``` +- **Set up fail2ban**: See [Set Up Fail2ban](/vps/fail2ban/) for SSH brute-force protection +- **Regular backups**: Use UpdraftPlus or [Automated Backups with Restic](/vps/automated-backups/) to back up your database and uploads + +--- + +## Troubleshooting + +**Cart and checkout pages not working with caching**: Make sure cart, checkout, and my-account URLs are excluded from the page cache. + +**500 error after installing WooCommerce**: Increase PHP memory limit to 512 MB. WooCommerce is memory-intensive. + +**SSL not working on checkout**: Go to **WooCommerce → Settings → Advanced → Pages** and verify all pages are set. Then **Settings → General → WordPress Address URL and Site Address URL** must start with `https://`. + +**PayPal IPN not working**: In PayPal, set the IPN URL to `https://yourdomain.com/wc-api/ipn-handler/`. + +--- + +## What's next + +- [WordPress security hardening](/wordpress/security/) +- [W3 Total Cache configuration](/wordpress/w3-total-cache/) +- [Self-hosting without a CDN: performance tips](/privacy/self-hosting-performance/) + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c3b7517 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + docs: + build: . + ports: + - "8080:8080" + environment: + PORT: "8080" + ADMIN_EMAIL: "${ADMIN_EMAIL}" + BILLING_URL: "${BILLING_URL:-https://portal.arcline.it}" + BILLING_DB: /data/billing/arcline-billing.db + DOCS_DB: /data/docs/arcline-docs.db + volumes: + - billing-data:/data/billing:ro + - docs-data:/data/docs + restart: unless-stopped + +volumes: + billing-data: + external: true + name: arcline_billing_data + docs-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d4b2d5 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module arclineit.com/docs + +go 1.23 + +require ( + github.com/fsnotify/fsnotify v1.10.1 + github.com/yuin/goldmark v1.7.8 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0a95f2f --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= + +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..acaade9 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,394 @@ +package store + +import ( + "database/sql" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// planCatalog mirrors billing's PlanCatalog — key → display name. +var planCatalog = map[string]string{ + "shared_starter": "Shared Starter", + "shared_pro": "Shared Pro", + "shared_business": "Shared Business", + "wp_starter": "WordPress Starter", + "wp_pro": "WordPress Pro", + "wp_business": "WordPress Business", + "vps_1": "VPS Tier 1", + "vps_2": "VPS Tier 2", + "vps_3": "VPS Tier 3", + "vps_4": "VPS Tier 4", +} + +// PlanOptions returns plan catalog entries for admin UI dropdowns. +func PlanOptions() []PlanOption { + order := []string{ + "shared_starter", "shared_pro", "shared_business", + "wp_starter", "wp_pro", "wp_business", + "vps_1", "vps_2", "vps_3", "vps_4", + } + out := make([]PlanOption, 0, len(order)) + for _, k := range order { + out = append(out, PlanOption{Key: k, Name: planCatalog[k]}) + } + return out +} + +type PlanOption struct { + Key string + Name string +} + +// Customer holds the fields docs needs from billing's customers table. +type Customer struct { + ID int64 + Email string + FirstName string + LastName string +} + +// Subscription holds the active subscription for a customer (may be nil). +type Subscription struct { + PriceID string + PlanName string + Status string +} + +// Page is a client or admin-managed doc page stored in docs.db. +type Page struct { + ID int64 + Title string + Slug string + Description string + Section string + Content string // raw markdown + Visibility string // "public" | "plan:key" | "customer:id" + DisplayOrder int + CreatedAt string + UpdatedAt string +} + +// Store wraps the billing DB (read-only) and docs DB (read-write). +type Store struct { + billing *sql.DB + docs *sql.DB +} + +// New opens both databases and migrates the docs schema. +// If the billing database file does not exist the store starts without it +// (auth and admin features will be unavailable, but public docs still serve). +func New(billingPath, docsPath string) (*Store, error) { + var billing *sql.DB + + if _, err := os.Stat(billingPath); err == nil { + billing, err = sql.Open("sqlite", billingPath+"?_journal_mode=WAL&mode=ro") + if err != nil { + return nil, fmt.Errorf("open billing db: %w", err) + } + if err := billing.Ping(); err != nil { + billing.Close() + billing = nil + slog.Warn("billing db ping failed, auth/admin disabled", "path", billingPath, "err", err) + } + } else { + slog.Warn("billing db not found, auth/admin disabled", "path", billingPath) + } + + // Ensure the docs directory exists before opening. + if err := os.MkdirAll(filepath.Dir(docsPath), 0o755); err != nil { + if billing != nil { + billing.Close() + } + return nil, fmt.Errorf("create docs dir: %w", err) + } + + docs, err := sql.Open("sqlite", docsPath+"?_journal_mode=WAL&_foreign_keys=on") + if err != nil { + if billing != nil { + billing.Close() + } + return nil, fmt.Errorf("open docs db: %w", err) + } + if err := docs.Ping(); err != nil { + if billing != nil { + billing.Close() + } + docs.Close() + return nil, fmt.Errorf("ping docs db: %w", err) + } + + if err := migrate(docs); err != nil { + if billing != nil { + billing.Close() + } + docs.Close() + return nil, fmt.Errorf("migrate docs db: %w", err) + } + + return &Store{billing: billing, docs: docs}, nil +} + +// Close closes both database connections. +func (s *Store) Close() { + if s.billing != nil { + s.billing.Close() + } + s.docs.Close() +} + +func migrate(db *sql.DB) error { + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + section TEXT NOT NULL DEFAULT 'client', + content TEXT NOT NULL DEFAULT '', + visibility TEXT NOT NULL DEFAULT 'public', + display_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + )`) + return err +} + +// ── Auth (reads billing DB) ──────────────────────────────────────────────────── + +// GetCustomerBySession looks up a billing session token and returns the +// associated customer. Returns nil, nil if the token is missing, expired, +// or billing db is not available. +func (s *Store) GetCustomerBySession(token string) (*Customer, error) { + if s.billing == nil { + return nil, nil + } + var customerID int64 + var expiresAt string + err := s.billing.QueryRow( + `SELECT customer_id, expires_at FROM sessions WHERE token = ?`, token, + ).Scan(&customerID, &expiresAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query session: %w", err) + } + + exp, err := time.Parse(time.RFC3339, expiresAt) + if err != nil || time.Now().UTC().After(exp) { + return nil, nil + } + + var c Customer + err = s.billing.QueryRow( + `SELECT id, email, first_name, last_name FROM customers WHERE id = ?`, customerID, + ).Scan(&c.ID, &c.Email, &c.FirstName, &c.LastName) + if err != nil { + return nil, fmt.Errorf("query customer: %w", err) + } + return &c, nil +} + +// GetSubscription returns the most recent active/cancelling subscription for +// the given customer, or nil if they have none. +func (s *Store) GetSubscription(customerID int64) (*Subscription, error) { + if s.billing == nil { + return nil, nil + } + var sub Subscription + err := s.billing.QueryRow(` + SELECT stripe_price_id, plan_name, status + FROM subscriptions + WHERE customer_id = ? + AND status IN ('active','cancelling') + ORDER BY created_at DESC + LIMIT 1`, + customerID, + ).Scan(&sub.PriceID, &sub.PlanName, &sub.Status) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query subscription: %w", err) + } + return &sub, nil +} + +// ListCustomers returns all billing customers (for admin visibility picker). +func (s *Store) ListCustomers() ([]Customer, error) { + rows, err := s.billing.Query( + `SELECT id, email, first_name, last_name FROM customers ORDER BY email`, + ) + if err != nil { + return nil, fmt.Errorf("list customers: %w", err) + } + defer rows.Close() + + var out []Customer + for rows.Next() { + var c Customer + if err := rows.Scan(&c.ID, &c.Email, &c.FirstName, &c.LastName); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// ── Visibility ──────────────────────────────────────────────────────────────── + +// CanSee reports whether a customer (with optional subscription) can view a +// page with the given visibility string. +func CanSee(c *Customer, sub *Subscription, visibility string) bool { + if visibility == "public" { + return true + } + if c == nil { + return false + } + if strings.HasPrefix(visibility, "customer:") { + tail := strings.TrimPrefix(visibility, "customer:") + return fmt.Sprintf("%d", c.ID) == tail + } + if strings.HasPrefix(visibility, "plan:") && sub != nil { + key := strings.TrimPrefix(visibility, "plan:") + name, ok := planCatalog[key] + if !ok { + return false + } + return sub.PlanName == name && (sub.Status == "active" || sub.Status == "cancelling") + } + return false +} + +// VisibilityLabel returns a human-readable label for a visibility string. +func VisibilityLabel(visibility string) string { + switch { + case visibility == "public": + return "Public" + case strings.HasPrefix(visibility, "plan:"): + key := strings.TrimPrefix(visibility, "plan:") + if name, ok := planCatalog[key]; ok { + return "Plan: " + name + } + return "Plan: " + key + case strings.HasPrefix(visibility, "customer:"): + return "Customer #" + strings.TrimPrefix(visibility, "customer:") + } + return visibility +} + +// ── Pages (reads/writes docs DB) ────────────────────────────────────────────── + +func (s *Store) ListPages() ([]*Page, error) { + rows, err := s.docs.Query(` + SELECT id, title, slug, description, section, content, + visibility, display_order, created_at, updated_at + FROM pages + ORDER BY section, display_order, title`) + if err != nil { + return nil, fmt.Errorf("list pages: %w", err) + } + defer rows.Close() + + var out []*Page + for rows.Next() { + var p Page + if err := rows.Scan(&p.ID, &p.Title, &p.Slug, &p.Description, &p.Section, + &p.Content, &p.Visibility, &p.DisplayOrder, &p.CreatedAt, &p.UpdatedAt); err != nil { + return nil, err + } + out = append(out, &p) + } + return out, rows.Err() +} + +// ListVisiblePages returns pages the given customer can see, ordered for display. +func (s *Store) ListVisiblePages(c *Customer, sub *Subscription) ([]*Page, error) { + all, err := s.ListPages() + if err != nil { + return nil, err + } + var out []*Page + for _, p := range all { + if CanSee(c, sub, p.Visibility) { + out = append(out, p) + } + } + return out, nil +} + +func (s *Store) GetPageBySlug(slug string) (*Page, error) { + var p Page + err := s.docs.QueryRow(` + SELECT id, title, slug, description, section, content, + visibility, display_order, created_at, updated_at + FROM pages WHERE slug = ?`, slug, + ).Scan(&p.ID, &p.Title, &p.Slug, &p.Description, &p.Section, + &p.Content, &p.Visibility, &p.DisplayOrder, &p.CreatedAt, &p.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get page by slug: %w", err) + } + return &p, nil +} + +func (s *Store) GetPageByID(id int64) (*Page, error) { + var p Page + err := s.docs.QueryRow(` + SELECT id, title, slug, description, section, content, + visibility, display_order, created_at, updated_at + FROM pages WHERE id = ?`, id, + ).Scan(&p.ID, &p.Title, &p.Slug, &p.Description, &p.Section, + &p.Content, &p.Visibility, &p.DisplayOrder, &p.CreatedAt, &p.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get page by id: %w", err) + } + return &p, nil +} + +func (s *Store) CreatePage(p *Page) (int64, error) { + res, err := s.docs.Exec(` + INSERT INTO pages (title, slug, description, section, content, visibility, display_order) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + p.Title, p.Slug, p.Description, p.Section, p.Content, p.Visibility, p.DisplayOrder, + ) + if err != nil { + return 0, fmt.Errorf("create page: %w", err) + } + return res.LastInsertId() +} + +func (s *Store) UpdatePage(p *Page) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.docs.Exec(` + UPDATE pages + SET title = ?, slug = ?, description = ?, section = ?, content = ?, + visibility = ?, display_order = ?, updated_at = ? + WHERE id = ?`, + p.Title, p.Slug, p.Description, p.Section, p.Content, + p.Visibility, p.DisplayOrder, now, p.ID, + ) + if err != nil { + return fmt.Errorf("update page: %w", err) + } + return nil +} + +func (s *Store) DeletePage(id int64) error { + _, err := s.docs.Exec(`DELETE FROM pages WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete page: %w", err) + } + return nil +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..ed5d408 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,49 @@ +server { + listen 80; + server_name _; + + # ------------------------------------------------------------------ + # HTTPS redirect — uncomment after running certbot: + # listen 443 ssl http2; + # ssl_certificate /etc/letsencrypt/live/docs.arcline.it/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/docs.arcline.it/privkey.pem; + # include /etc/letsencrypt/options-ssl-nginx.conf; + # add_header Strict-Transport-Security "max-age=63072000" always; + # + # And add a redirect block: + # server { listen 80; server_name _; return 301 https://$host$request_uri; } + # ------------------------------------------------------------------ + + root /usr/share/nginx/html; + index index.html; + + # Custom 404 + error_page 404 /404.html; + + # robots.txt at root + location = /robots.txt { + try_files /robots.txt =404; + } + + # Clean URLs: /section/page/ → /section/page/index.html + location / { + try_files $uri $uri/ $uri/index.html =404; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Cache static assets aggressively + location ~* \.(css|js|woff2?|ttf|otf|svg|png|ico|webp|jpg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Never cache HTML, JSON, or XML + location ~* \.(html|json|xml)$ { + expires -1; + add_header Cache-Control "no-cache"; + } +} diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..9b564e7 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,834 @@ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --white: #ffffff; + --bg: #f8fafc; + --border: #e2e8f0; + --border-subtle: #f1f5f9; + --text: #475569; + --text-strong: #1e293b; + --text-muted: #94a3b8; + --accent: #0077cc; + --accent-dark: #005fa3; + --accent-light: #eff6ff; + --accent-border: #bfdbfe; + --cyan: #00c8f0; + --green: #16a34a; + --dark: #0f172a; + --radius-sm: 6px; + --radius: 12px; + --radius-lg: 16px; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); + --shadow-md: 0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.06); + --t: 0.15s ease; + --sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace; +} + +html { scroll-behavior: smooth; } +body { + font-family: var(--sans); + background: var(--white); + color: var(--text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +img { max-width: 100%; display: block; } +a { color: inherit; } + +/* ── CONTAINER ── */ +.container { + max-width: 1100px; + margin: 0 auto; + padding: 0 1.5rem; +} + +/* ── BUTTONS ── */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.625rem 1.25rem; + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-weight: 600; + font-family: var(--sans); + text-decoration: none; + cursor: pointer; + border: none; + transition: all var(--t); + white-space: nowrap; + line-height: 1; +} +.btn--primary { background: var(--accent); color: #fff; } +.btn--primary:hover { background: var(--accent-dark); } +.btn--ghost { background: transparent; color: var(--text); padding: 0.625rem 0.875rem; } +.btn--ghost:hover { color: var(--text-strong); background: var(--bg); } +.btn--sm { padding: 0.4rem 0.875rem; font-size: 0.8125rem; } + +/* ── NAV ── */ +.nav { + position: sticky; + top: 0; + z-index: 100; + background: rgba(255,255,255,0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); +} + +.nav__inner { + display: flex; + align-items: center; + height: 60px; + gap: 1rem; +} + +.nav__logo { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 700; + color: var(--text-strong); + text-decoration: none; + flex-shrink: 0; + letter-spacing: -0.01em; +} +.nav__logo svg { flex-shrink: 0; } + +.nav__docs-badge { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); + background: var(--accent-light); + border: 1px solid var(--accent-border); + border-radius: 100px; + padding: 0.2em 0.6em; + text-decoration: none; + flex-shrink: 0; +} + +.nav__search-wrap { + flex: 1; + max-width: 360px; + position: relative; +} + +.nav__search { + width: 100%; + font-family: var(--sans); + font-size: 0.875rem; + color: var(--text-strong); + background: var(--bg); + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.45rem 0.875rem; + outline: none; + transition: border-color var(--t), box-shadow var(--t); +} +.nav__search::placeholder { color: var(--text-muted); } +.nav__search:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(0,119,204,0.12); + background: var(--white); +} + +.nav__actions { + display: flex; + gap: 0.5rem; + margin-left: auto; + align-items: center; +} + +.nav__hamburger { + display: none; + flex-direction: column; + gap: 5px; + background: none; + border: none; + cursor: pointer; + padding: 6px; + margin-left: auto; + flex-shrink: 0; +} +.nav__hamburger span { + display: block; + width: 22px; + height: 2px; + background: var(--text-strong); + border-radius: 2px; + transition: all 0.2s; +} + +.nav__mobile { + display: none; + flex-direction: column; + position: fixed; + top: 60px; + left: 0; right: 0; + z-index: 99; + background: var(--white); + border-top: 1px solid var(--border); + padding: 0.75rem 1.5rem 1.25rem; + gap: 0; + box-shadow: 0 4px 16px rgba(0,0,0,0.08); + max-height: calc(100vh - 60px); + overflow-y: auto; +} +.nav__mobile.open { display: flex; } +.nav__mobile-section { + font-size: 0.675rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + padding: 1rem 0 0.375rem; +} +.nav__mobile a { + font-size: 0.9rem; + font-weight: 500; + color: var(--text-strong); + text-decoration: none; + padding: 0.625rem 0; + border-bottom: 1px solid var(--border-subtle); + display: block; +} +.nav__mobile a.active { color: var(--accent); font-weight: 600; } +.nav__mobile-actions { + display: flex; + gap: 0.75rem; + padding-top: 1rem; +} +.nav__mobile-actions .btn { flex: 1; justify-content: center; } + +/* ── BREADCRUMB ── */ +.breadcrumb { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-muted); + margin-bottom: 1.75rem; +} +.breadcrumb a { + color: var(--text-muted); + text-decoration: none; + transition: color var(--t); +} +.breadcrumb a:hover { color: var(--accent); } +.breadcrumb__sep { color: var(--border); font-weight: 400; } + +/* ── FOOTER ── */ +.footer { + background: var(--dark); + border-top: 1px solid rgba(255,255,255,0.06); + padding: 3.5rem 0 2rem; + margin-top: auto; +} +.footer__grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 3rem; + margin-bottom: 2.5rem; +} +.footer__brand { max-width: 280px; } +.footer__logo { + display: flex; + align-items: center; + gap: 0.5rem; + color: #f1f5f9; + font-weight: 700; + font-size: 0.9375rem; + margin-bottom: 0.875rem; + text-decoration: none; + letter-spacing: -0.01em; +} +.footer__tagline { font-size: 0.8125rem; color: #475569; line-height: 1.65; margin-bottom: 1.125rem; } +.footer__status { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.75rem; + font-weight: 600; + color: #22c55e; + text-decoration: none; +} +.footer__status::before { + content: ''; + width: 6px; height: 6px; + border-radius: 50%; + background: #22c55e; + box-shadow: 0 0 0 2px rgba(34,197,94,0.2); +} +.footer__col-title { + font-size: 0.675rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #475569; + margin-bottom: 1rem; +} +.footer__links { list-style: none; display: flex; flex-direction: column; gap: 0.5rem; } +.footer__link { font-size: 0.8125rem; color: #475569; text-decoration: none; transition: color var(--t); } +.footer__link:hover { color: #94a3b8; } +.footer__bottom { + border-top: 1px solid rgba(255,255,255,0.06); + padding-top: 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} +.footer__copy { font-size: 0.75rem; color: #475569; } +.footer__legal { display: flex; gap: 1.25rem; } +.footer__legal a { font-size: 0.75rem; color: #475569; text-decoration: none; } +.footer__legal a:hover { color: #64748b; } + +/* ── RESPONSIVE ── */ +@media (max-width: 960px) { +@media (max-width: 768px) { + .nav__actions { display: none; } + .nav__search-wrap { display: none; } + .nav__docs-menu { display: none; } + .nav__hamburger { display: flex; } +} + .footer__grid { grid-template-columns: 1fr 1fr; gap: 2rem; } +} +@media (max-width: 768px) { + .nav__actions { display: none; } + .nav__search-wrap { display: none; } + .nav__hamburger { display: flex; } +} +@media (max-width: 600px) { + .footer__grid { grid-template-columns: 1fr 1fr; gap: 1.5rem; } +} +======= +/* ============================================================ + base.css — Arcline design system base + ============================================================ */ + +/* ── ROOT ── */ +:root { + --sans: 'Inter', -apple-system, 'Segoe UI', Roboto, sans-serif; + --mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + --accent: #0ea5e9; + --accent-dark: #0284c7; + --accent-light: #e0f2fe; + --accent-border:#bae6fd; + --bg: #f8fafc; + --white: #ffffff; + --text: #334155; + --text-strong: #0f172a; + --text-muted: #64748b; + --border: #e2e8f0; + --border-subtle:#f1f5f9; + --radius: 8px; + --radius-sm: 6px; + --shadow-md: 0 4px 12px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04); + --t: 0.15s ease; +} + +/* ── RESET ── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { -webkit-text-size-adjust: 100%; } +body { + font-family: var(--sans); + font-size: 1rem; + color: var(--text); + background: var(--white); + line-height: 1.6; + min-height: 100vh; +} +img, svg { max-width: 100%; height: auto; display: block; } + +/* ── UTILITIES ── */ +.container { max-width: 1200px; margin: 0 auto; padding: 0 1.5rem; } + +/* ── BUTTONS ── */ +.btn { + display: inline-flex; align-items: center; gap: 0.375rem; + font-family: var(--sans); font-weight: 600; + font-size: 0.875rem; padding: 0.625rem 1.25rem; + border-radius: var(--radius-sm); border: 1px solid transparent; + text-decoration: none; cursor: pointer; + transition: background var(--t), border-color var(--t), color var(--t), box-shadow var(--t); + line-height: 1.3; +} +.btn--primary { background: var(--accent); color: #fff; } +.btn--primary:hover { background: var(--accent-dark); box-shadow: 0 2px 8px rgba(14,165,233,0.3); } +.btn--ghost { background: transparent; border-color: var(--border); color: var(--text); } +.btn--ghost:hover { border-color: #cbd5e1; background: var(--bg); color: var(--text-strong); } +.btn--danger { background: transparent; border-color: #fecaca; color: #dc2626; } +.btn--danger:hover { background: #fef2f2; border-color: #f87171; } +.btn--sm { font-size: 0.775rem; padding: 0.4rem 0.875rem; } + +/* ── NAV ── */ +.nav { + background: var(--white); + border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 100; + height: 60px; display: flex; align-items: center; +} +.nav__inner { + display: flex; align-items: center; gap: 1rem; + width: 100%; +} +.nav__logo { + display: flex; align-items: center; gap: 0.5rem; + font-size: 1.05rem; font-weight: 800; + color: var(--text-strong); text-decoration: none; + letter-spacing: -0.03em; +} +.nav__docs-badge { + font-size: 0.775rem; font-weight: 700; letter-spacing: 0.04em; + text-transform: uppercase; color: var(--accent); + text-decoration: none; background: var(--accent-light); + padding: 0.2rem 0.5rem; border-radius: 3px; + transition: color var(--t), background var(--t); +} +.nav__docs-badge:hover { color: var(--accent-dark); background: var(--accent-border); } +.nav__actions { display: flex; gap: 0.5rem; margin-left: auto; white-space: nowrap; } +.nav__search-wrap { flex: 0 1 320px; margin-left: auto; } +.nav__search { + width: 100%; font-family: var(--sans); font-size: 0.875rem; + padding: 0.5rem 0.75rem; border: 1px solid var(--border); + border-radius: var(--radius-sm); background: var(--bg); + color: var(--text-strong); outline: none; + transition: border-color var(--t), box-shadow var(--t); +} +.nav__search:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(14,165,233,0.15); } +.nav__search::placeholder { color: var(--text-muted); } + +/* ── HAMBURGER ── */ +.nav__hamburger { + display: none; flex-direction: column; gap: 4px; + background: none; border: none; cursor: pointer; + padding: 0.375rem; +} +.nav__hamburger span { + display: block; width: 20px; height: 2px; + background: var(--text); border-radius: 1px; + transition: transform var(--t), opacity var(--t); +} + +/* ── MOBILE NAV ── */ +.nav__mobile { + display: none; background: var(--white); + border-bottom: 1px solid var(--border); + padding: 1rem 1.5rem 1.5rem; + position: sticky; top: 60px; z-index: 99; + max-height: calc(100vh - 60px); overflow-y: auto; +} +.nav__mobile.open { display: block; } +.nav__mobile-section { + font-size: 0.675rem; font-weight: 700; + letter-spacing: 0.1em; text-transform: uppercase; + color: var(--text-muted); margin: 1rem 0 0.375rem; +} +.nav__mobile-section:first-child { margin-top: 0; } +.nav__mobile a { + display: block; font-size: 0.875rem; color: var(--text); + text-decoration: none; padding: 0.4rem 0.5rem; + border-radius: var(--radius-sm); + transition: color var(--t), background var(--t); +} +.nav__mobile a:hover { color: var(--text-strong); background: var(--border-subtle); } +.nav__mobile a.active { color: var(--accent); font-weight: 600; background: var(--accent-light); } +.nav__mobile-actions { display: flex; gap: 0.5rem; margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--border); } + +/* ── BREADCRUMB ── */ +.breadcrumb { + display: flex; align-items: center; gap: 0.375rem; + font-size: 0.8125rem; color: var(--text-muted); + margin-bottom: 1.75rem; +} +.breadcrumb a { color: var(--text-muted); text-decoration: none; } +.breadcrumb a:hover { color: var(--accent); } +.breadcrumb__sep { color: var(--border); font-size: 0.75rem; } + +/* ── FOOTER ── */ +.footer { + background: #0f172a; color: #94a3b8; + padding: 3rem 1.5rem; margin-top: 3rem; + font-size: 0.875rem; +} +.footer__grid { + display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 3rem; margin-bottom: 2rem; +} +.footer__logo { + display: flex; align-items: center; gap: 0.5rem; + font-size: 1.125rem; font-weight: 800; + color: #f1f5f9; text-decoration: none; margin-bottom: 0.75rem; +} +.footer__tagline { font-size: 0.8125rem; line-height: 1.65; margin-bottom: 0.75rem; } +.footer__status { + display: inline-flex; align-items: center; gap: 0.4rem; + font-size: 0.8125rem; font-weight: 600; + color: #22c55e; text-decoration: none; +} +.footer__status::before { + content: ''; display: block; width: 8px; height: 8px; + border-radius: 50%; background: #22c55e; +} +.footer__col-title { + font-size: 0.675rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #475569; + margin-bottom: 1rem; +} +.footer__links { list-style: none; display: flex; flex-direction: column; gap: 0.5rem; } +.footer__link { font-size: 0.8125rem; color: #475569; text-decoration: none; transition: color var(--t); } +.footer__link:hover { color: #94a3b8; } +.footer__bottom { + border-top: 1px solid rgba(255,255,255,0.06); + padding-top: 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} +.footer__copy { font-size: 0.75rem; color: #475569; } +.footer__legal { display: flex; gap: 1.25rem; } +.footer__legal a { font-size: 0.75rem; color: #475569; text-decoration: none; } +.footer__legal a:hover { color: #64748b; } + +/* ── RESPONSIVE ── */ +@media (max-width: 960px) { + .footer__grid { grid-template-columns: 1fr 1fr; gap: 2rem; } +} + +@media (max-width: 768px) { + .nav__actions { display: none; } + .nav__search-wrap { display: none; } + .nav__docs-menu { display: none; } + .nav__hamburger { display: flex; } +} + +@media (max-width: 600px) { + .footer__grid { grid-template-columns: 1fr 1fr; gap: 1.5rem; } +} + ============================================================ */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --white: #ffffff; + --bg: #f8fafc; + --border: #e2e8f0; + --border-subtle: #f1f5f9; + --text: #475569; + --text-strong: #1e293b; + --text-muted: #94a3b8; + --accent: #0077cc; + --accent-dark: #005fa3; + --accent-light: #eff6ff; + --accent-border: #bfdbfe; + --cyan: #00c8f0; + --green: #16a34a; + --dark: #0f172a; + --radius-sm: 6px; + --radius: 12px; + --radius-lg: 16px; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); + --shadow-md: 0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.06); + --t: 0.15s ease; + --sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace; +} + +html { scroll-behavior: smooth; } +body { + font-family: var(--sans); + background: var(--white); + color: var(--text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +img { max-width: 100%; display: block; } +a { color: inherit; } + +/* ── CONTAINER ── */ +.container { + max-width: 1100px; + margin: 0 auto; + padding: 0 1.5rem; +} + +/* ── BUTTONS ── */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.625rem 1.25rem; + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-weight: 600; + font-family: var(--sans); + text-decoration: none; + cursor: pointer; + border: none; + transition: all var(--t); + white-space: nowrap; + line-height: 1; +} +.btn--primary { background: var(--accent); color: #fff; } +.btn--primary:hover { background: var(--accent-dark); } +.btn--ghost { background: transparent; color: var(--text); padding: 0.625rem 0.875rem; } +.btn--ghost:hover { color: var(--text-strong); background: var(--bg); } +.btn--sm { padding: 0.4rem 0.875rem; font-size: 0.8125rem; } + +/* ── NAV ── */ +.nav { + position: sticky; + top: 0; + z-index: 100; + background: rgba(255,255,255,0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); +} + +.nav__inner { + display: flex; + align-items: center; + height: 60px; + gap: 1rem; +} + +.nav__logo { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 700; + color: var(--text-strong); + text-decoration: none; + flex-shrink: 0; + letter-spacing: -0.01em; +} +.nav__logo svg { flex-shrink: 0; } + +.nav__docs-badge { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); + background: var(--accent-light); + border: 1px solid var(--accent-border); + border-radius: 100px; + padding: 0.2em 0.6em; + text-decoration: none; + flex-shrink: 0; +} + +.nav__search-wrap { + flex: 1; + max-width: 360px; + position: relative; +} + +.nav__search { + width: 100%; + font-family: var(--sans); + font-size: 0.875rem; + color: var(--text-strong); + background: var(--bg); + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.45rem 0.875rem; + outline: none; + transition: border-color var(--t), box-shadow var(--t); +} +.nav__search::placeholder { color: var(--text-muted); } +.nav__search:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(0,119,204,0.12); + background: var(--white); +} + +.nav__actions { + display: flex; + gap: 0.5rem; + margin-left: auto; + align-items: center; +} + +.nav__hamburger { + display: none; + flex-direction: column; + gap: 5px; + background: none; + border: none; + cursor: pointer; + padding: 6px; + margin-left: auto; + flex-shrink: 0; +} +.nav__hamburger span { + display: block; + width: 22px; + height: 2px; + background: var(--text-strong); + border-radius: 2px; + transition: all 0.2s; +} + +.nav__mobile { + display: none; + flex-direction: column; + position: fixed; + top: 60px; + left: 0; right: 0; + z-index: 99; + background: var(--white); + border-top: 1px solid var(--border); + padding: 0.75rem 1.5rem 1.25rem; + gap: 0; + box-shadow: 0 4px 16px rgba(0,0,0,0.08); + max-height: calc(100vh - 60px); + overflow-y: auto; +} +.nav__mobile.open { display: flex; } +.nav__mobile-section { + font-size: 0.675rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + padding: 1rem 0 0.375rem; +} +.nav__mobile a { + font-size: 0.9rem; + font-weight: 500; + color: var(--text-strong); + text-decoration: none; + padding: 0.625rem 0; + border-bottom: 1px solid var(--border-subtle); + display: block; +} +.nav__mobile a.active { color: var(--accent); font-weight: 600; } +.nav__mobile-actions { + display: flex; + gap: 0.75rem; + padding-top: 1rem; +} +.nav__mobile-actions .btn { flex: 1; justify-content: center; } + +/* ── BREADCRUMB ── */ +.breadcrumb { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-muted); + margin-bottom: 1.75rem; +} +.breadcrumb a { + color: var(--text-muted); + text-decoration: none; + transition: color var(--t); +} +.breadcrumb a:hover { color: var(--accent); } +.breadcrumb__sep { color: var(--border); font-weight: 400; } + +/* ── FOOTER ── */ +.footer { + background: var(--dark); + border-top: 1px solid rgba(255,255,255,0.06); + padding: 3.5rem 0 2rem; + margin-top: auto; +} +.footer__grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 3rem; + margin-bottom: 2.5rem; +} +.footer__brand { max-width: 280px; } +.footer__logo { + display: flex; + align-items: center; + gap: 0.5rem; + color: #f1f5f9; + font-weight: 700; + font-size: 0.9375rem; + margin-bottom: 0.875rem; + text-decoration: none; + letter-spacing: -0.01em; +} +.footer__tagline { font-size: 0.8125rem; color: #475569; line-height: 1.65; margin-bottom: 1.125rem; } +.footer__status { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.75rem; + font-weight: 600; + color: #22c55e; + text-decoration: none; +} +.footer__status::before { + content: ''; + width: 6px; height: 6px; + border-radius: 50%; + background: #22c55e; + box-shadow: 0 0 0 2px rgba(34,197,94,0.2); +} +.footer__col-title { + font-size: 0.675rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #475569; + margin-bottom: 1rem; +} +.footer__links { list-style: none; display: flex; flex-direction: column; gap: 0.5rem; } +.footer__link { font-size: 0.8125rem; color: #475569; text-decoration: none; transition: color var(--t); } +.footer__link:hover { color: #94a3b8; } +.footer__bottom { + border-top: 1px solid rgba(255,255,255,0.06); + padding-top: 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} +.footer__copy { font-size: 0.75rem; color: #475569; } +.footer__legal { display: flex; gap: 1.25rem; } +.footer__legal a { font-size: 0.75rem; color: #475569; text-decoration: none; } +.footer__legal a:hover { color: #64748b; } + +/* ── RESPONSIVE ── */ +@media (max-width: 960px) { +@media (max-width: 768px) { + .nav__actions { display: none; } + .nav__search-wrap { display: none; } + .nav__docs-menu { display: none; } + .nav__hamburger { display: flex; } +} + .footer__grid { grid-template-columns: 1fr 1fr; gap: 2rem; } +} +@media (max-width: 768px) { + .nav__actions { display: none; } + .nav__search-wrap { display: none; } + .nav__hamburger { display: flex; } +} +@media (max-width: 600px) { + .footer__grid { grid-template-columns: 1fr 1fr; gap: 1.5rem; } +} diff --git a/static/css/docs.css b/static/css/docs.css new file mode 100644 index 0000000..46c2dac --- /dev/null +++ b/static/css/docs.css @@ -0,0 +1,530 @@ +/* ============================================================ + docs.css — Inline document layout and content styles + ============================================================ */ + +/* ── MAIN CONTENT ── */ +.docs-main { + max-width: 780px; + margin: 0 auto; + padding: 2.5rem 2rem 5rem; +} + +/* ── ARTICLE ── */ +.docs-article h1 { + font-size: clamp(1.75rem, 3.5vw, 2.5rem); + font-weight: 800; + color: var(--text-strong); + letter-spacing: -0.035em; + line-height: 1.1; + margin-bottom: 0.75rem; +} + +.docs-article > h1 + p { + font-size: 1.1rem; + color: var(--text); + line-height: 1.7; + margin-bottom: 2.5rem; + padding-bottom: 1.75rem; + border-bottom: 1px solid var(--border); +} + +/* ── INLINE TABLE OF CONTENTS ── */ +.docs-toc { + margin-bottom: 2.5rem; +} + +.docs-toc__details { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0; + overflow: hidden; +} + +.docs-toc__summary { + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + padding: 0.875rem 1.25rem; + cursor: pointer; + user-select: none; + list-style: none; + transition: color var(--t); +} +.docs-toc__summary::-webkit-details-marker { display: none; } +.docs-toc__summary:hover { color: var(--text-strong); } +.docs-toc__summary::before { + content: '\25b8 '; + font-size: 0.7rem; + color: var(--text-muted); + transition: transform var(--t); +} +.docs-toc__details[open] .docs-toc__summary::before { content: '\25be '; } + +.docs-toc__links { + padding: 0 1.25rem 1rem; + border-top: 1px solid var(--border-subtle); +} + +.docs-toc__list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0; +} + +.docs-toc__sublist { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.125rem; + padding-left: 1.25rem; + margin-top: 0.125rem; +} + +.docs-toc__item { + font-size: 0.875rem; + line-height: 1.5; +} + +.docs-toc__item a { + display: block; + padding: 0.3rem 0.625rem; + border-radius: var(--radius-sm); + color: var(--text); + text-decoration: none; + transition: color var(--t), background var(--t); +} +.docs-toc__item a:hover { + color: var(--accent); + background: var(--accent-light); +} + +.docs-toc__item--sub a { + font-size: 0.825rem; + color: var(--text-muted); + padding-left: 0.75rem; +} +.docs-toc__item--sub a:hover { color: var(--text); } + +/* ── PROSE ── */ +.prose h2 { + font-size: 1.5rem; + font-weight: 800; + color: var(--text-strong); + letter-spacing: -0.025em; + line-height: 1.2; + margin-top: 3rem; + margin-bottom: 0.75rem; + padding-top: 2rem; + border-top: 1px solid var(--border); +} +.prose h2:first-child { margin-top: 0; padding-top: 0; border-top: none; } + +.prose h3 { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-strong); + margin-top: 2rem; + margin-bottom: 0.5rem; +} + +.prose h4 { + font-size: 0.95rem; + font-weight: 700; + color: var(--text-strong); + margin-top: 1.5rem; + margin-bottom: 0.375rem; +} + +.prose p { + font-size: 1rem; + color: var(--text); + line-height: 1.82; + margin-bottom: 1.125rem; +} +.prose p:last-child { margin-bottom: 0; } + +.prose ul, .prose ol { + padding-left: 1.5rem; + margin-bottom: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.prose li { + font-size: 0.9625rem; + color: var(--text); + line-height: 1.75; +} + +.prose li > ul, .prose li > ol { + margin-top: 0.5rem; + margin-bottom: 0.25rem; +} + +.prose a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; +} +.prose a:hover { color: var(--accent-dark); } + +.prose strong { + font-weight: 600; + color: var(--text-strong); +} + +.prose hr { + border: none; + border-top: 1px solid var(--border); + margin: 2.5rem 0; +} + +.prose table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + margin: 1.5rem 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; + display: block; + overflow-x: auto; +} +.prose th { + background: var(--bg); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + padding: 0.625rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.prose td { + padding: 0.75rem 1rem; + color: var(--text); + border-bottom: 1px solid var(--border-subtle); + vertical-align: top; + line-height: 1.55; +} +.prose tr:last-child td { border-bottom: none; } + +/* ── CODE ── */ +.prose code { + font-family: var(--mono); + font-size: 0.845em; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.15em 0.4em; + color: var(--text-strong); +} + +.prose pre { + background: #0f172a; + border-radius: var(--radius); + padding: 1.375rem 1.5rem; + overflow-x: auto; + margin: 1.5rem 0; + border: 1px solid rgba(255,255,255,0.06); +} + +.prose pre code { + font-family: var(--mono); + font-size: 0.875rem; + background: none; + border: none; + padding: 0; + color: #e2e8f0; + line-height: 1.7; +} + +/* ── CALLOUT BOXES ── */ +.prose blockquote { + background: var(--accent-light); + border: 1px solid var(--accent-border); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + padding: 1rem 1.25rem; + margin: 1.5rem 0; +} +.prose blockquote p { + font-size: 0.9375rem; + color: var(--text-strong); + line-height: 1.65; + margin: 0; +} + +/* ── SECTION INDEX ── */ +.section-index { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.section-index__card { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding: 1.25rem 1.375rem; + background: var(--white); + border: 1px solid var(--border); + border-radius: var(--radius); + text-decoration: none; + transition: box-shadow var(--t), border-color var(--t); +} +.section-index__card:hover { + box-shadow: var(--shadow-md); + border-color: #cbd5e1; +} + +.section-index__title { + font-size: 0.9375rem; + font-weight: 700; + color: var(--accent); +} + +.section-index__desc { + font-size: 0.845rem; + color: var(--text); + line-height: 1.6; +} + +/* ── HOME PAGE ── */ +.docs-home__intro { + font-size: 1.1rem; + color: var(--text); + line-height: 1.72; + margin-bottom: 2.5rem; + max-width: 620px; +} + +.docs-home__section { + margin-bottom: 2.5rem; +} + +.docs-home__section-title { + font-size: 1.25rem; + font-weight: 800; + color: var(--text-strong); + letter-spacing: -0.02em; + margin-bottom: 0.875rem; +} +.docs-home__section-title a { + color: inherit; + text-decoration: none; + transition: color var(--t); +} +.docs-home__section-title a:hover { color: var(--accent); } + +.docs-home__list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-left: 0; + margin: 0; +} + +.docs-home__list li { + font-size: 0.9375rem; + color: var(--text); + line-height: 1.6; +} + +.docs-home__list a { + color: var(--accent); + font-weight: 500; + text-decoration: none; +} +.docs-home__list a:hover { text-decoration: underline; } + +.docs-home__desc { color: var(--text-muted); font-weight: 400; } + +/* ── NAV DROPDOWN ── */ +.nav__docs-menu { + position: relative; +} + +.nav__browse-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.45rem 0.875rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text); + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + font-family: var(--sans); + white-space: nowrap; + transition: border-color var(--t), background var(--t); +} +.nav__browse-btn:hover { + border-color: var(--accent); + background: var(--accent-light); +} +.nav__browse-btn::after { + content: '\25be'; + font-size: 0.6rem; + margin-top: 2px; +} + +.nav__dropdown { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 260px; + max-height: 70vh; + overflow-y: auto; + background: var(--white); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0,0,0,0.1), 0 2px 8px rgba(0,0,0,0.06); + z-index: 200; + padding: 0.5rem 0; +} +.nav__dropdown.open { display: block; } + +.nav__dropdown-section { + padding: 0.5rem 1rem 0.125rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +.nav__dropdown-link { + display: block; + padding: 0.45rem 1rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--text); + text-decoration: none; + transition: background var(--t); +} +.nav__dropdown-link:hover { background: var(--bg); color: var(--text-strong); } +.nav__dropdown-link--active { color: var(--accent); font-weight: 600; } + +/* ── PAGER ── */ +.docs-pager { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-top: 3.5rem; + padding-top: 2.5rem; + border-top: 1px solid var(--border); +} + +.docs-pager__side { flex: 1; } +.docs-pager__side--right { text-align: right; } + +.docs-pager__link { + display: inline-flex; + flex-direction: column; + gap: 0.25rem; + padding: 1rem 1.25rem; + background: var(--white); + border: 1px solid var(--border); + border-radius: var(--radius); + text-decoration: none; + transition: box-shadow var(--t), border-color var(--t); + max-width: 300px; +} +.docs-pager__link:hover { box-shadow: var(--shadow-md); border-color: #cbd5e1; } +.docs-pager__link--next { margin-left: auto; text-align: right; } + +.docs-pager__dir { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.docs-pager__title { + font-size: 0.9rem; + font-weight: 600; + color: var(--accent); +} + +/* ── SEARCH RESULTS ── */ +.search-wrap { position: relative; flex: 1 1 auto; max-width: 320px; } + +.search-results { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: var(--white); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0,0,0,0.1), 0 2px 8px rgba(0,0,0,0.06); + z-index: 200; + overflow: hidden; + max-height: 400px; + overflow-y: auto; +} + +.search-result { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.875rem 1.125rem; + border-bottom: 1px solid var(--border-subtle); + text-decoration: none; + transition: background var(--t); +} +.search-result:last-child { border-bottom: none; } +.search-result:hover { background: var(--bg); } + +.search-result__section { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} + +.search-result__title { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-strong); + line-height: 1.3; +} + +.search-result__excerpt { + font-size: 0.8125rem; + color: var(--text-muted); + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── RESPONSIVE ── */ +@media (max-width: 900px) { + .docs-main { padding: 2rem 1.5rem 3.5rem; } +} + +@media (max-width: 600px) { + .docs-main { padding: 1.5rem 1rem 2.5rem; } + .section-index { grid-template-columns: 1fr; } + .docs-pager { flex-direction: column; } + .docs-pager__link--next { margin-left: 0; text-align: left; } + .nav__browse-btn span.label { display: none; } +} diff --git a/static/css/fonts.css b/static/css/fonts.css new file mode 100644 index 0000000..f5a0cbc --- /dev/null +++ b/static/css/fonts.css @@ -0,0 +1,35 @@ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url('/public/fonts/inter-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url('/public/fonts/inter-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url('/public/fonts/jbmono-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url('/public/fonts/jbmono-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} diff --git a/static/js/search.js b/static/js/search.js new file mode 100644 index 0000000..914f1c5 --- /dev/null +++ b/static/js/search.js @@ -0,0 +1,93 @@ +(function () { + 'use strict'; + + var index = null; + var input = document.getElementById('search-input'); + var results = document.getElementById('search-results'); + if (!input || !results) return; + + function root() { + var meta = document.querySelector('meta[name="docs-root"]'); + return meta ? meta.content : ''; + } + + function loadIndex(cb) { + if (index) { cb(); return; } + fetch(root() + 'search.json') + .then(function (r) { return r.json(); }) + .then(function (data) { index = data; cb(); }) + .catch(function () {}); + } + + function esc(s) { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function search(q) { + if (!index) return; + q = q.trim(); + if (!q) { hide(); return; } + + var terms = q.toLowerCase().split(/\s+/).filter(Boolean); + var matches = index.filter(function (doc) { + var text = (doc.title + ' ' + doc.section + ' ' + doc.excerpt).toLowerCase(); + return terms.every(function (t) { return text.indexOf(t) !== -1; }); + }).slice(0, 8); + + if (!matches.length) { hide(); return; } + + results.innerHTML = matches.map(function (doc) { + return '' + + '' + esc(doc.section) + '' + + '' + esc(doc.title) + '' + + '' + esc(doc.excerpt) + '' + + ''; + }).join(''); + results.hidden = false; + } + + function hide() { results.hidden = true; } + + input.addEventListener('focus', function () { loadIndex(function () {}); }); + + input.addEventListener('input', function () { + loadIndex(function () { search(input.value); }); + }); + + document.addEventListener('click', function (e) { + if (!e.target.closest('.search-wrap')) hide(); + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { hide(); input.blur(); } + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + input.focus(); + input.select(); + } + }); + + // Keyboard navigation inside results + results.addEventListener('keydown', function (e) { + var links = results.querySelectorAll('.search-result'); + var active = results.querySelector('.search-result:focus'); + var idx = Array.prototype.indexOf.call(links, active); + if (e.key === 'ArrowDown') { + e.preventDefault(); + var next = links[idx + 1] || links[0]; + if (next) next.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + var prev = links[idx - 1] || links[links.length - 1]; + if (prev) prev.focus(); + } + }); + + input.addEventListener('keydown', function (e) { + if (e.key === 'ArrowDown' && !results.hidden) { + e.preventDefault(); + var first = results.querySelector('.search-result'); + if (first) first.focus(); + } + }); +})(); diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..e75d51c --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / +Sitemap: https://docs.arcline.it/sitemap.xml + diff --git a/templates/admin_edit.html b/templates/admin_edit.html new file mode 100644 index 0000000..3158a3b --- /dev/null +++ b/templates/admin_edit.html @@ -0,0 +1,184 @@ + + + + {{template "head" .}} + {{if .Page}}Edit: {{.Page.Title}}{{else}}New Page{{end}} — Admin — Arcline + + + + + +{{template "nav" .}} + +
+
+

{{if .Page}}Edit page{{else}}New page{{end}}

+
+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ + +
+
+ + +
+ +
+ + + URL: /client/slug/ +
+ +
+ + +
+ +
+ + + Use "client" for customer-facing docs. +
+ +
+ + +
+ +
+ +
+ + + +
+ +
+ + Visible to all customers on this plan with an active subscription. +
+ +
+ + Visible to this customer only. +
+
+ +
+ + +
+
+ +
+ + Cancel + {{if .Page}} + View page → + {{end}} +
+
+
+ +{{template "footer-simple" .}} + + + + diff --git a/templates/admin_pages.html b/templates/admin_pages.html new file mode 100644 index 0000000..666d53a --- /dev/null +++ b/templates/admin_pages.html @@ -0,0 +1,87 @@ + + + + {{template "head" .}} + Admin — Docs — Arcline + + + + + +{{template "nav" .}} + +
+
+

Docs Admin

+ + New page +
+ + {{if .Pages}} + + + + + + + + + + + + + {{range .Pages}} + + + + + + + + + {{end}} + +
TitleSlugSectionVisibilityOrder
+ {{.Title}} + {{if .Description}}
{{.Description}}{{end}} +
{{.Slug}}{{.Section}} + {{$v := visLabel .Visibility}} + {{if eq .Visibility "public"}} + {{$v}} + {{else if hasPrefix .Visibility "plan:"}} + {{$v}} + {{else}} + {{$v}} + {{end}} + {{.DisplayOrder}} +
+ View + Edit +
+ + +
+
+
+ {{else}} +

No pages yet. Create the first one.

+ {{end}} +
+ +{{template "footer-simple" .}} + + diff --git a/templates/client_index.html b/templates/client_index.html new file mode 100644 index 0000000..4d87920 --- /dev/null +++ b/templates/client_index.html @@ -0,0 +1,39 @@ + + + + {{template "head" .}} + My Docs — Arcline + + + +{{template "nav" .}} + +
+ + +
+

My Docs

+

Private documentation and guides for your Arcline account.

+
+ + {{if .Pages}} + + {{else}} +

No private documentation has been added to your account yet.

+ {{end}} +
+ +{{template "footer-simple" .}} + + diff --git a/templates/client_page.html b/templates/client_page.html new file mode 100644 index 0000000..4236ff9 --- /dev/null +++ b/templates/client_page.html @@ -0,0 +1,34 @@ + + + + {{template "head" .}} + {{.Page.Title}} — My Docs — Arcline + {{if .Page.Description}}{{end}} + + + + +{{template "nav" .}} + +
+ + + +
+ +{{template "footer-simple" .}} + + diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..511c752 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,181 @@ +{{define "head"}} + + + + + + + + +{{end}} + +{{define "nav"}} + + +{{if .Nav}} + +{{end}} + + +{{end}} + +{{define "footer"}} + +{{end}} + +{{define "footer-simple"}} + +{{end}} diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..97670fa --- /dev/null +++ b/templates/page.html @@ -0,0 +1,94 @@ + + + + {{template "head" .}} + {{.Title}} + {{if .Description}}{{end}} + {{if .Canonical}}{{end}} + + + + {{if .Description}}{{end}} + + + +{{template "nav" .}} + +
+ + {{if .Breadcrumbs}} + + {{end}} + +
+ {{if .Nav}} + + {{end}} + + {{.Content}} +
+ + {{if or .Prev .Next}} + + {{end}} +
+ +{{template "footer" .}} + + + + diff --git a/test_write.md b/test_write.md new file mode 100644 index 0000000..ab71a4a --- /dev/null +++ b/test_write.md @@ -0,0 +1,12 @@ +# Test file +This is a test file with multiple lines. +Line 2 +Line 3 +Line 4 +Line 5 +Line 6 +Line 7 +Line 8 +Line 9 +Line 10 + diff --git a/todo.md b/todo.md index 8716791..9c6b333 100644 --- a/todo.md +++ b/todo.md @@ -1,65 +1,87 @@ # arcline-docs — Knowledge Base & Migration Guides SEO-friendly documentation, migration guides, and self-hosting tutorials. -High-conversion content that directly supports the Arcline pitch. +Hosted at: docs.arclineit.com + +## Status + +**Complete.** All content sections are written. The static site builder generates a fully functional documentation site with search, sitemap, RSS feed, and 404 pages. ## Format -- Static HTML (generated from Markdown via a simple Go builder, or hand-written) +- Static HTML (generated from Markdown via Go builder using goldmark) - Arcline design system (same CSS as website) -- Hosted at: docs.arclineit.com +- Client-side search (JSON index with keyboard navigation) +- RSS feed (rss.xml) for new guides +- Sitemap generation (sitemap.xml) -## Content Plan +## Content -### Migration Guides (priority — high SEO, high conversion) -- [ ] Migrate from GoDaddy Shared Hosting to Arcline -- [ ] Migrate from Bluehost to Arcline -- [ ] Migrate from SiteGround to Arcline -- [ ] Migrate from Namecheap to Arcline -- [ ] Migrate from HostGator to Arcline -- [ ] Migrate WordPress from managed WP hosts (WP Engine, Kinsta) to Arcline VPS -- [ ] Transfer a domain to Arcline (registrar transfer walkthrough) +### Migration Guides ✅ +- [x] Migrate from GoDaddy Shared Hosting to Arcline +- [x] Migrate from Bluehost to Arcline +- [x] Migrate from SiteGround to Arcline +- [x] Migrate from Namecheap to Arcline +- [x] Migrate from HostGator to Arcline +- [x] Migrate from WP Engine to Arcline VPS +- [x] Transfer a domain to Arcline (registrar transfer walkthrough) -### Getting Started -- [ ] How to connect to your server via SSH -- [ ] How to upload files via SFTP (FileZilla, Cyberduck) -- [ ] How to create and restore a MySQL database backup -- [ ] How to set up email (MX records, cPanel email accounts) -- [ ] How to point your domain's nameservers to Arcline -- [ ] How to install an SSL certificate (Let's Encrypt via cPanel) +### Getting Started ✅ +- [x] How to connect to your server via SSH +- [x] How to upload files via SFTP (FileZilla, Cyberduck) +- [x] How to create and restore a MySQL database backup +- [x] How to set up email (MX records, cPanel email accounts) +- [x] How to point your domain's nameservers to Arcline +- [x] How to install an SSL certificate (Let's Encrypt via cPanel) -### WordPress -- [ ] Installing WordPress on shared hosting -- [ ] Installing WordPress on a VPS (LAMP stack) -- [ ] Configuring W3 Total Cache without a CDN -- [ ] WordPress security hardening (no third-party CDN required) -- [ ] Setting up WooCommerce on Arcline +### WordPress ✅ +- [x] Installing WordPress on shared hosting +- [x] Installing WordPress on a VPS (LAMP stack) +- [x] Configuring W3 Total Cache without a CDN +- [x] WordPress security hardening +- [x] Setting up WooCommerce on Arcline -### VPS Guides -- [ ] Initial VPS setup (Debian/Ubuntu): users, SSH keys, ufw firewall -- [ ] Install Nginx + PHP-FPM + MySQL on Debian -- [ ] Deploy a static site with Nginx -- [ ] Deploy a Node.js app with PM2 + Nginx -- [ ] Deploy a Go binary as a systemd service -- [ ] Set up automated backups with restic -- [ ] Set up fail2ban for SSH brute-force protection +### VPS Guides ✅ +- [x] Initial VPS setup (Debian/Ubuntu): users, SSH keys, ufw firewall +- [x] Install Nginx + PHP-FPM + MySQL on Debian +- [x] Deploy a static site with Nginx +- [x] Deploy a Node.js app with PM2 + Nginx +- [x] Deploy a Go binary as a systemd service +- [x] Set up automated backups with restic +- [x] Set up fail2ban for SSH brute-force protection -### Privacy & Self-Hosting -- [ ] Why you shouldn't put Cloudflare in front of everything -- [ ] How to check if your host is actually self-hosted (arcline-check walkthrough) -- [ ] Self-hosting without a CDN: performance tips -- [ ] What SPF, DKIM, and DMARC actually do (and how to set them up) +### Privacy & Self-Hosting ✅ +- [x] Why you shouldn't put Cloudflare in front of everything +- [x] How to check if your host is actually self-hosted (arcline-check walkthrough) +- [x] Self-hosting without a CDN: performance tips +- [x] What SPF, DKIM, and DMARC actually do (and how to set them up) -### Reference -- [ ] Arcline nameservers and DNS records -- [ ] Supported PHP versions -- [ ] Resource limits by plan -- [ ] Acceptable Use Policy summary -- [ ] How to open a support ticket +### Reference ✅ +- [x] Arcline nameservers and DNS records +- [x] Supported PHP versions +- [x] Resource limits by plan +- [x] Acceptable Use Policy summary +- [x] How to open a support ticket -## Site Builder -- [ ] Simple Go Markdown → HTML builder (goldmark) -- [ ] Template: Arcline design system header/footer/sidebar -- [ ] Search: client-side (pagefind or simple JSON index) -- [ ] Sitemap.xml generation -- [ ] RSS feed for new guides -- [ ] Build script: watch mode for local dev +## Site Builder ✅ +- [x] Simple Go Markdown → HTML builder (goldmark) +- [x] Template: Arcline design system header/footer/sidebar +- [x] Search: client-side JSON index +- [x] Sitemap.xml generation +- [x] RSS feed for new guides (rss.xml) +- [x] Build script: watch mode for local dev (`make build-static-watch`) + +## Development + +```bash +# Build the dynamic server +make build + +# Run the dev server +make run + +# Build the static site (outputs to dist/) +make build-static + +# Watch mode — rebuilds on content/template changes +make build-static-watch +```