Prima di avviare lo stack è necessario configurare i record DNS presso il proprio provider (OVH):
| Nome | Tipo | Valore |
|---|---|---|
example.com |
A | <IP del server> |
CNAME *.example.com → example.com.
⚠️ Attenzione: Il CNAME wildcard indirizza qualunque sottodominio (portainer.example.com,app.example.com, ecc.) alla zona principale. Su OVH, se la zona radice è un record A, il CNAME wildcard è supportato. In caso di problemi, creare singoli record A per ogni servizio:
Nome Tipo Valore portainerA <IP del server>appA <IP del server>
Lo stack usa la stessa codebase (docker-compose.yml) sia in locale che sul server: quello che cambia è solo il file .env passato a Compose, tramite Docker Compose profiles.
Locale (nessuna modifica necessaria, certificato NPM self-signed, VPN/CrowdSec/Bouncer spenti):
cd services
docker compose --env-file .env.local up -dI servizi con dominio (NPM, Portainer via proxy) sono raggiungibili su *.127.0.0.1.nip.io (wildcard DNS pubblico che risolve a 127.0.0.1).
Attenzione: --profile da riga di comando sostituisce COMPOSE_PROFILES del file env. Se avvii altri profili a mano (es. --profile server), passa gli stessi --profile anche a down, altrimenti i servizi degli altri profili restano in esecuzione.
Server (stack completo, certificati Let's Encrypt reali):
cd services
cp example.env .env # solo la prima volta: personalizzare dominio/credenziali
docker compose up -d # legge .env di default, con COMPOSE_PROFILES=serversetup.sh (vedi sotto) automatizza anche questo passaggio.
services/.env (con le credenziali reali del server) non è versionato: usare services/example.env come riferimento. services/.env.local invece è versionato con valori fittizi pensati solo per lo sviluppo in locale.
sudo apt update && sudo apt install -y git
git clone https://github.com/dima91/server-setup.git
cd server-setup
sudo bash setup.sh <new_user> <new_user_passw> <ssh_port>Il file setup.sh è uno script Bash pensato per automatizzare la configurazione iniziale di un server (tipicamente una VPS o un server dedicato) con sistema Debian/Ubuntu. Lo script viene eseguito in più fasi (stage), riavviando la macchina tra una fase e l’altra per applicare modifiche a basso livello (es. cambio di porta SSH, rimozione dell’utente predefinito). Ecco una descrizione dettagliata delle operazioni effettuate, fase per fase.
- Lo script deve essere eseguito come root.
- Accetta tre parametri:
<new_user>,<new_user_passw>,<ssh_port>. - Durante la prima esecuzione crea una cartella persistente
/etc/server_setup, vi copia l’intero script e genera un filesetup_infoche tiene traccia dei parametri e dello stage corrente. - Al termine di ogni stage viene aggiornato il file
setup_infoe viene riavviato il sistema. Dopo il reboot, l’utente indicato (prima l’old user, poi il nuovo) deve rilanciare lo scriptsetup.shper proseguire.
- Verifica che lo script sia eseguito come root.
- Controlla che la cartella
/etc/server_setupnon esista già (altrimenti salta questa fase e procede con lo stage successivo). - Acquisisce:
- OLD_USER = utente che ha lanciato
sudo(o$USERse non usato sudo). - NEW_USER, NEW_USER_PASSW, NEW_SSH_PORT dai parametri.
- I gruppi secondari dell’utente originale (
id -Gn <OLD_USER>, escluso il gruppo primario, confrontato per nome intero: un gruppodev-opsnon viene toccato se il primario èdev).
- OLD_USER = utente che ha lanciato
- Chiede conferma all’utente mostrando tutti i valori.
- Crea la cartella
SETUP_DATA_FOLDER(/etc/server_setup) con modo0700(solo root: la copia del repo può contenereservices/.envcon le credenziali e resta lì fino allo stage 4), vi copia l’intera directory contenente lo script, ne imposta i permessi e crea un symlink in/usr/local/bin/setup.sh. Con un numero di parametri sbagliato si ferma prima di creare qualsiasi cosa. - Scrive il file
setup_infocon tutti i dati eNEXT_STAGE=1. Il file contiene la password in chiaro e viene riletto consourceda root a ogni stage: i valori sono quotati (printf %q, nessun carattere speciale viene interpretato) e il file è leggibile solo da root (0600). La scrittura è atomica (file temporaneo +mv): se si interrompe, il file di stato precedente resta intatto e lo stage si ferma con un errore. - Riavvia il sistema.
- Esegue
apt update && apt upgrade. - Installa i pacchetti essenziali elencati in
ESSENTIAL_PACKAGES(strumenti come build-essential, curl, git, ufw, htop, net-tools, ecc.). - Crea il nuovo utente
NEW_USER, gli assegna gli stessi gruppi (aggiungendosudo), imposta la password e aggiunge l’aliasupdgnel file.bash_aliases. - Crea un file di swap eseguendo lo script
make_swap.sh:/swapfilegrande quanto la RAM (anche col fallbackdd), registrato in/etc/fstab. Se/swapfileè già attivo non fa nulla; sefallocate/dd,mkswaposwaponfalliscono esce con errore, senza dichiarare lo swap pronto né scrivere infstab. - Aggiorna il file
setup_infoconNEXT_STAGE=2. - Riavvia il sistema.
- Rimuove completamente l’utente originale
OLD_USER(utente di default di molte VPS, es.debian). - Modifica la porta SSH nel file
/etc/ssh/sshd_configimpostandola aNEW_SSH_PORT. Disabilita anche il login di root via SSH. - Riavvia il servizio
sshd. Sessh.socketè attivo (Ubuntu 22.10+: con l'attivazione via socketsshdignoraPortdisshd_config) lo disattiva e abilitassh. Poi verifica consschesshdascolti davvero sulla nuova porta: se no si ferma con un errore (NEXT_STAGEresta 2) prima di abilitare UFW e riavviare, perché UFW chiude la 22 e il server resterebbe senza nessun accesso SSH. Risolto il problema basta rilanciaresetup.sh. - Configura UFW:
- Abilita IPv6.
- Imposta policy: deny in ingresso, allow in uscita.
- Apre la nuova porta SSH.
- Attiva UFW (
ufw enable:systemctl enableda solo non basta,/etc/ufw/ufw.confparte conENABLED=no) e lo abilita all'avvio. La sessione SSH in corso non cade: la porta è già aperta.
- Installa Docker:
- Rileva la distribuzione (Debian/Ubuntu) tramite
hostnamectl. - Rimuove gli eventuali vecchi pacchetti Docker/containerd/runc in conflitto (solo quelli installati, senza conferma interattiva).
- Aggiunge la repository ufficiale Docker.
- Installa
docker-ce,docker-ce-cli,containerd.ioe i plugin necessari. - Aggiunge il nuovo utente al gruppo
docker.
- Rileva la distribuzione (Debian/Ubuntu) tramite
- Aggiorna il file
setup_infoconNEXT_STAGE=3. - Riavvia il sistema. Prima del riavvio il log indica dove creare il
.envdello stage 3:/etc/server_setup/<cartella del repo>/services/.env(daexample.env, consudo: la cartella è solo di root).
- Ferma e disabilita Apache (se installato) per liberare le porte 80/443.
- Apre le porte firewall necessarie: 80, 443, 1195 (VPN personale). Portainer non ha una porta propria: si raggiunge da
portainer.<dominio>tramite NPM. L'admin di NPM (81) e Open WebUI (3000) sono pubblicate solo su localhost: da fuori Open WebUI si raggiunge dawebui.<dominio>(con TLS e controllo CrowdSec), l'admin di NPM con un tunnel SSH (ssh -L 8081:127.0.0.1:81 -p <porta> <utente>@<server>, poihttp://127.0.0.1:8081). - Copia la cartella
servicesnella home del nuovo utente (se esiste già la riusa: modifica lì il.env) e la intesta al nuovo utente; il.env, con le credenziali, è0600. - Si posiziona nella directory
servicese:- Verifica che
.envesista (altrimenti si ferma: copiaexample.envin.enve rilancia). Il file non viene "sourced": è la sintassi di Compose, non quella della shell. - Verifica che nel
.envnon sia rimasto nessun valore diexample.env: segnapostochange_me_*,SERVER_DOMAIN=example.com, email@example.com(Let's Encrypt le rifiuta). Se ce ne sono si ferma prima di avviare qualsiasi cosa e stampa i nomi delle variabili (mai i valori): senza questo controllo Portainer nascerebbe con una password nota, e dà accesso al socket di Docker, cioè root sull'host. - Genera una password casuale per il database di NPM e la inserisce nel file
.enval posto del segnapostoNPM_DB_PASSWORD=change_me_npm_db_password(onpm_db_password, anche tra virgolette o con spazi finali); la riga deve essere solo il segnaposto: una password scelta a mano, anche se inizia allo stesso modo (npm_db_password_prod), non viene toccata.
- Verifica che
- Crea la rete docker
proxynetse non esiste (nel compose èexternal). - Avvia tutti i container con
docker compose up -d. Se fallisce lo stage si ferma, senza avanzare. - Attende i container di configurazione
npm-inite, se il profiloportainerè attivo,portainer-init(docker wait, massimo 20 minuti ciascuno) e ne controlla l'esito: se uno esce con errore (certificato Let's Encrypt non ottenuto, login fallito, admin Portainer non creato) lo stage si ferma senza avanzare. Rilanciandosetup.sh,docker compose up -driavvia gli init già usciti. Niente più attesa a tempo: CrowdSec ha un controllo di prontezza suo. - Se il profilo
serverè attivo (CrowdSec tra i servizi di Compose; altrimenti salta tutto questo):- Attende che CrowdSec sia davvero pronto:
cscli lapi status, fino a 24 tentativi (cscli versionrisponde subito, prima che la LAPI sia su). - Genera la chiave API del bouncer con
cscli bouncers add npm-bouncer(se il bouncer esiste già da un tentativo precedente lo ricrea) e la scrive in.env(CROWDSEC_BOUNCER_API_KEY). - Ricrea il container
cs-bouncerper applicare la nuova chiave e verifica che risponda200al ForwardAuth. - Solo a questo punto imposta
CROWDSEC_FORWARD_AUTH_URLin.enve ricreanpm-init, che attiva il controllo su tutti i proxy host. Fino ad allora i proxy host sono raggiungibili senza controllo, ma non c'è nessuna finestra in cui rispondano 403/500: vedi CrowdSec e NPM. Senpm-initfallisce (ad esempio nginx rifiuta il blocco), l'attivazione viene annullata: l'URL torna vuota in.envenpm-initviene rilanciato per togliere il blocco da NPM. - Se uno di questi passaggi fallisce lo stage si ferma con un errore senza avanzare (
NEXT_STAGEresta 3): risolto il problema basta rilanciaresetup.sh.
- Attende che CrowdSec sia davvero pronto:
- Aggiorna il file
setup_infoconNEXT_STAGE=4. - Riavvia il sistema.
- La fase successiva (NEXT_STAGE diverso da 1,2,3) stampa semplicemente “Setup is finished. System ready!” e termina, perché tutto è stato configurato.
log(): scrive messaggi con timestamp su file/var/log/setup.loge su console.update_info_file(): riscrive il filesetup_infocon i parametri attuali e il prossimo stage (in modo atomico; se non riesce lo stage si ferma).
Una volta completato lo script, il server avrà:
- Un nuovo utente con permessi sudo e gruppi ereditati dall’utente originale.
- Accesso SSH su una porta personalizzata (e root disabilitato).
- Firewall UFW attivo con sole porte necessarie aperte (SSH, 80, 443, 1195).
- Swap configurato (grande quanto la RAM).
- Docker installato e utente aggiunto al gruppo docker.
- Stack di servizi in esecuzione: Nginx Proxy Manager, CrowdSec, un CS bouncer per NPM, un database per NPM, probabilmente Portainer, un server VPN e Open WebUI (a giudicare dalle porte aperte).
- Integrazione automatica tra CrowdSec e NPM tramite la chiave bouncer generata.
Lo script è pensato per essere eseguito una sola volta e gestisce i riavvii in modo trasparente, richiedendo all’utente di rilanciare il comando setup.sh dopo ogni reboot.
Prima di procedere con il setup manuale o automatico, assicurarsi che i record DNS siano configurati correttamente.
| Record | Nome | Tipo | Target |
|---|---|---|---|
| Root | example.com |
A | <IP del server> |
| Wildcard | *.example.com |
CNAME | example.com. |
Il CNAME wildcard è obbligatorio per far funzionare tutti i sottodomini (Portainer, App, ecc.) senza dover creare un record A per ciascuno.
Nota per OVH: Se la zona DNS non accetta un CNAME sulla radice, creare record A espliciti per ogni servizio:
Nome Tipo Valore portainerA <IP del server>appA <IP del server>
- Configurare i record DNS (vedi sezione "DNS prerequisites" sopra)
- Create NPM certificates:
certbot certonly --config /etc/letsencrypt.ini --work-dir /tmp/letsencrypt-lib --logs-dir /data/logs --cert-name ${CERTIFICATE_NAME} --agree-tos --authenticator webroot -m ${ADMIN_EMAIL} --preferred-challenges http --domains ${FULL_DOMAIN} --key-type ecdsa
- Add NPM hosts via NPM UI
Con il profilo server CrowdSec e il bouncer sono collegati a NPM in due punti:
- Rilevamento: CrowdSec legge i log di NPM (
/var/log/nginx/logs/proxy-host-*.log, volumenpm_datain sola lettura) grazie aservices/crowdsec/acquis.yaml, montato in/etc/crowdsec/acquis.yaml. L'etichettatype: nginx-proxy-managerè quella su cui filtra il parser della collection. - Blocco: se
CROWDSEC_FORWARD_AUTH_URLè valorizzata in.env,npm-initscrive in/data/nginx/custom/server_proxy.conf(volumenpm_data, montato in lettura/scrittura innpm-init) un blocco con unauth_requestversocs-bouncer. NPM include quel file in ogni proxy host, anche quelli creati dalla UI e non presenti nel descriptor: gli IP con una decisione di ban ricevono 403. Il blocco è delimitato da marcatori e il resto del file non viene toccato; se la variabile è vuota il blocco viene rimosso. Le richieste/.well-known/acme-challenge/sono sempre ammesse: sono la validazione HTTP-01 di Let's Encrypt, e con il bouncer giù o la chiave non valida il rinnovo dei certificati fallirebbe in silenzio.
Il blocco è fail-closed: con una chiave bouncer non valida, o con cs-bouncer fermo o assente, i proxy host rispondono 403/500. Per questo CROWDSEC_FORWARD_AUTH_URL in example.env è vuota: la imposta setup.sh (stage 3) dopo aver generato la chiave e verificato che il bouncer risponda, e solo se il profilo server è attivo. Se la imposti a mano, il profilo server deve essere attivo; se lo togli da COMPOSE_PROFILES svuota anche la variabile. In locale (.env.local) non è definita e non cambia nulla.
Su un server già installato prima di questa modifica, dopo aver aggiornato il repo:
cd services
docker compose up -d # ricrea npm e open-webui con le porte 81 e 3000 solo su localhost, e npm-init con il nuovo volume
docker compose up -d npm-init # riallinea gli host e scrive il blocco globale (se la variabile è già in .env)Se nginx rifiuta il blocco (tipico: un host creato dalla UI ha ancora il vecchio snippet per-host, e location = /crowdsec-auth risulterebbe due volte), npm-init esce con errore dopo aver ripristinato server_proxy.conf com'era e aver risalvato l'host perché NPM lo riattivi: il file rifiutato non resta sul volume (altrimenti ogni salvataggio successivo, anche dalla UI, darebbe errore). Per risolvere togli l'advanced_config con il vecchio snippet da quell'host e rilancia docker compose up -d npm-init. Se un file rifiutato è già rimasto sul volume (versioni precedenti), svuota CROWDSEC_FORWARD_AUTH_URL e rilancia npm-init per rimuovere il blocco.
npm-init prima allinea gli host del descriptor (l'advanced_config del vecchio snippet per-host sparisce) e solo dopo scrive il blocco globale, poi fa ricaricare nginx. Se CROWDSEC_FORWARD_AUTH_URL non è ancora in .env, aggiungi CROWDSEC_FORWARD_AUTH_URL=http://cs-bouncer:8080/api/v1/forwardAuth solo dopo aver verificato che il bouncer risponda: docker exec npm curl -s -o /dev/null -w '%{http_code}' http://cs-bouncer:8080/api/v1/forwardAuth deve stampare 200 (403 = chiave non valida).
I test sono in tests/ e non toccano il sistema: setup.sh gira in una sandbox con i percorsi di sistema riscritti e sudo, apt, ufw, systemctl, docker, reboot... sostituiti da shim che registrano le chiamate; npm-init gira contro un NPM finto (tests/mock_npm.py); la parte ACME usa un nginx vero in Docker.
tests/run.sh # tutti
tests/run.sh t_stage3 t_swap # solo alcuni
SS_ROOT=/percorso/di/un/albero tests/run.sh # prova un altro albero (es. un vecchio commit estratto con git archive)Servono bash, jq, curl, python3; t_nginx_acme usa Docker e salta se manca l'immagine nginx:1.27-alpine. Non sostituiscono la prova sullo stack vero (vedi la skill verify): lo stage 3 e sshd reali non si possono eseguire in sandbox.
docker exec crowdsec cscli alerts list # IP bannati
docker exec crowdsec cscli decisions list # decisioni attive
docker exec crowdsec cscli metrics # statistiche