Cómo desplegué este sitio
Publicado el 19 de julio de 2026
Esta es la configuración exacta que usé para desplegar fabricioblasich.com.
El hardware
Compré un VPS en RackNerd con estas especificaciones:
- 1 dirección IPv4
- 2.5 GB de RAM
- 2 núcleos de CPU
- 45 GB de disco
- 3 TB bandwidth
- Ubuntu 24.04 64-bit
- Ubicación: Nueva York
Me costó $18 USD por un año. Pagué con Bitcoin, así que no se aplicaron impuestos.
Blindaje del VPS
Un VPS recién provisionado es un objetivo. La contraseña root por defecto es débil, y el usuario actual es root con acceso total al sistema. Así es como lo aseguré.
Conexión por SSH y actualización de paquetes
Tu proveedor de VPS te da un usuario root, una IP pública y una contraseña. Conectate inmediatamente:
ssh root@ip
Lo primero que hay que hacer es actualizar todo. Los atacantes explotan vulnerabilidades conocidas en paquetes desactualizados, así que mantener todos los paquetes en sus últimas versiones cierra esos vectores de ataque antes de que cualquier otra cosa toque el servidor:
root@ubuntu:~# apt update
root@ubuntu:~# apt upgrade
Una actualización del kernel puede requerir un reinicio. Verificalo con:
root@ubuntu:~# ls /var/run/reboot-required
/var/run/reboot-required
Si ese archivo existe, reiniciá desde el panel de control de tu proveedor de VPS.
Cambio de la contraseña root por defecto
La contraseña que tu proveedor te envió por email está comprometida desde el momento en que salió de su bandeja de salida. Cambiala:
root@ubuntu:~# passwd
root@ubuntu:~# exit
Volvé a conectarte por SSH con tu nueva contraseña.
Creación de un usuario sin privilegios root con acceso sudo
Creá un usuario regular con una contraseña fuerte (diferente a la de root) y otorgale privilegios de sudo:
root@ubuntu:~# adduser fblasich
Este usuario todavía no puede hacer nada como superusuario. Agregalo al grupo sudo:
root@ubuntu:~# usermod -aG sudo fblasich
root@ubuntu:~# groups fblasich
fblasich : fblasich sudo
root@ubuntu:~# exit
Cerrá la sesión de root e iniciá sesión como el nuevo usuario:
ssh fblasich@ip
Reemplazo de autenticación por contraseña con claves SSH
Las contraseñas pueden ser vulneradas por fuerza bruta. Las claves SSH no. Generá un par de claves en tu máquina local (no en el VPS). GitHub tiene una guía sobre generación de claves SSH.
En Linux, la clave pública se encuentra en /home/VOSOTROS/.ssh/id_ALGORITMO:
cat /home/fblasich/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC4NzaC1lZDI1NTE5AAAAIAuZ9+jY7MvBcehTjzc9G6bEGbME6SMi3dFJxdT7KF5F [email protected]
Copiá esa clave pública. En el VPS, creá el directorio .ssh con los permisos correctos y un archivo authorized_keys, luego pegala:
fblasich@ubuntu:~# mkdir -p ~/.ssh
fblasich@ubuntu:~# chmod 700 ~/.ssh
fblasich@ubuntu:~# nano ~/.ssh/authorized_keys
fblasich@ubuntu:~# chmod 600 ~/.ssh/authorized_keys
fblasich@ubuntu:~# exit
Los permisos importan acá. SSH se rehúsa a usar claves si el directorio o el archivo son legibles por otros usuarios.
Volvé a conectarte por SSH. No te va a pedir contraseña. El par de claves se encargó de la negociación.
Desactivación del login por contraseña
Si te conectás desde múltiples máquinas, configurá una clave SSH en cada una y agregá todas las claves públicas a authorized_keys antes de continuar.
Editá la configuración del demonio SSH:
fblasich@ubuntu:~# sudo nano /etc/ssh/sshd_config
Buscá PasswordAuthentication yes y cambialo a PasswordAuthentication no. Confirmá que PubkeyAuthentication yes esté configurado. Guardá el archivo.
Cloud-init puede sobrescribir esto. Verificá y editá también el archivo de sobrescritura:
fblasich@ubuntu:~# sudo nano /etc/ssh/sshd_config.d/50-cloud-init.conf
Configurá PasswordAuthentication no ahí también. Reiniciá el servicio SSH:
fblasich@ubuntu:~# sudo service ssh restart
fblasich@ubuntu:~# exit
Probalo. Intentá iniciar sesión como root (que no tiene clave SSH configurada):
ssh root@ip
root@ip: Permission denied (publickey).
Tu usuario regular sigue funcionando porque tiene una clave. Todos los demás se encuentran con una puerta cerrada.
Si algo sale mal y tu clave no funciona, usá la consola VNC de tu proveedor de VPS para iniciar sesión directamente y corregir los permisos de las claves (ver los pasos de chmod 700 y chmod 600 arriba).
Desactivación completa del login de root
Incluso con la autenticación por contraseña desactivada, la cuenta root sigue siendo un objetivo conocido. Eliminala de SSH por completo:
fblasich@ubuntu:~# sudo nano /etc/ssh/sshd_config
Buscá # PermitRootLogin, descomentalo, y configuralo como PermitRootLogin no. Guardá y reiniciá:
fblasich@ubuntu:~# sudo service ssh restart
Root ya no puede iniciar sesión por SSH.
Cierre de puertos no utilizados
Mantené abiertos solo los puertos mínimos que tus servicios realmente usan. La mayoría de los proveedores de VPS vienen con los puertos 22 (SSH), 80 (HTTP) y 443 (HTTPS) ya abiertos en UFW. Verificalo con:
fblasich@ubuntu:~# sudo ufw status
Deberías ver esos tres puertos permitidos. Si UFW está inactivo, activalo:
fblasich@ubuntu:~# sudo ufw allow 22/tcp
fblasich@ubuntu:~# sudo ufw allow 80/tcp
fblasich@ubuntu:~# sudo ufw allow 443/tcp
fblasich@ubuntu:~# sudo ufw enable
El puerto 22 queda abierto por ahora. Lo vas a cerrar más adelante, después de que NetBird esté funcionando y manejando SSH por la red privada.
Activación de actualizaciones de seguridad automáticas
Las actualizaciones manuales se olvidan. Dejá que el sistema se parchee solo:
fblasich@ubuntu:~# sudo apt install unattended-upgrades
fblasich@ubuntu:~# sudo dpkg-reconfigure unattended-upgrades
fblasich@ubuntu:~# sudo nano /etc/apt/apt.conf.d/50unattended-upgrades
fblasich@ubuntu:~# sudo systemctl status unattended-upgrades
Por defecto, solo las actualizaciones de seguridad están habilitadas.

Aislamiento de SSH detrás de una VPN privada con NetBird
En este punto el servidor está blindado, pero el puerto 22 sigue expuesto a internet. Quería cerrarlo completamente y solo permitir SSH a través de una red privada.
NetBird es una VPN mesh basada en WireGuard. Permite gestionar hasta 100 peers de forma gratuita. Una vez configurado, cada dispositivo en la mesh obtiene una IP privada 100.x.x.x y puede alcanzar directamente a cualquier otro dispositivo.
Configuración de NetBird en tu laptop
Instalá NetBird en tu máquina local e iniciá sesión. Después ejecutá:
netbird up
Agregado del VPS como peer servidor
Andá al panel de control web de NetBird, hacé clic en Add Peer y seleccioná Server (no Device). Los peers de tipo servidor no expiran sus sesiones.
- Instalá NetBird en el VPS:
curl -fsSL https://pkgs.netbird.io/install.sh | sh
-
Generá una setup key en el dashboard de NetBird.
-
Ejecutá en el VPS:
netbird up --setup-key SETUP_KEY
Verificá la conexión:
netbird status
Ahora podés conectarte por SSH al VPS a través de la red privada desde cualquier dispositivo en la mesh:
ssh [email protected]
Rotación de setup keys
Mantener la misma setup key indefinidamente es un riesgo. Rotala cada 6 a 12 meses: cerrá sesión de NetBird en el VPS, conectate por VNC (RackNerd provee acceso VNC desde el navegador), generá una nueva setup key y volvé a ejecutar netbird up con la nueva clave.
Forzar todo el tráfico SSH a través de la VPN
NetBird crea el túnel seguro, pero no gestiona el firewall. Para forzar todo el tráfico SSH a través de la VPN y bloquear el acceso público, configuré UFW manualmente.
Permití tráfico entrante exclusivamente a través de la interfaz de NetBird:
sudo ufw allow in on wt0
Después cerrá el puerto SSH público:
sudo ufw delete allow 22/tcp
Cuando instalé NetBird y ejecuté netbird up, el demonio orquestó automáticamente un túnel WireGuard. Creó la interfaz wt0, manejó la criptografía y asignó la IP privada (100.81.129.137).
La red de seguridad: Cerrar el puerto SSH público crea un punto único de fallo. Si NetBird se cae o una actualización rompe el demonio, quedás bloqueado. Solo procedí con esta configuración estricta porque RackNerd provee acceso KVM/VNC fuera de banda a través de su panel de control. Si NetBird falla, puedo abrir la consola del navegador desde el panel de hosting y arreglar el servidor localmente.

Debugging de fallos de DNS causados por NetBird
Después de instalar NetBird, los contenedores de Docker dejaron de resolver nombres de dominio. Este es un efecto secundario de cómo NetBird gestiona el DNS en el host.
Síntomas
bun install se colgaba sin output durante los builds, tanto en Dokploy como en docker build manual:
docker run --rm alpine nslookup registry.npmjs.org
# connection timed out; no servers could be reached
docker run --rm oven/bun:1.3-alpine sh -c "wget ... registry.npmjs.org"
# bad address 'registry.npmjs.org'
Causa raíz
NetBird gestiona /etc/resolv.conf en el host, configurando el nameserver como 100.81.82.4 (una IP interna de la VPN). Docker hereda esa configuración en los contenedores. Pero los contenedores viven en la red bridge de Docker (172.17.0.x) y no tienen ruta hacia la red de NetBird (100.81.x.x). El resultado es un timeout de DNS silencioso sin mensaje de error.
La solución
Forzar DNS público para todos los contenedores:
echo '{"dns": ["8.8.8.8", "1.1.1.1"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
Verificación
nslookup registry.npmjs.org resolvió 12 IPs. bun install completó: 367 paquetes en 5.84s. Los builds de Docker volvieron a funcionar.
Dominio y HTTPS con Dokploy
Compré fabricioblasich.com en Cloudflare y configuré los registros A:

Instalación de Dokploy
Dokploy es un PaaS de código abierto que corre en tu VPS. Maneja despliegues Docker, HTTPS y reverse proxy a través de Traefik.
curl -sSL https://dokploy.com/install.sh | sh
Accedé al dashboard usando tu IP privada de NetBird:
http://100.x.x.x:3000
Es HTTP plano, pero es seguro porque el tráfico viaja a través del túnel WireGuard cifrado. No expongas el puerto 3000 a internet público.
Configuración de HTTPS con Let’s Encrypt
Iniciá sesión y creá una contraseña fuerte. Después andá a Web Server Settings.
En tu proveedor de DNS, creá un registro A apuntando admin.tudominio a la IP de tu servidor. Después en el dashboard de Dokploy:
- Domain: ingresá
admin.tudominio - Let’s Encrypt: ingresá tu dirección de email real
- HTTPS: seleccioná Let’s Encrypt como proveedor
Hacé clic en guardar. Abrí admin.tudominio en una nueva pestaña. Carga por HTTPS. Iniciá sesión y empezá a desplegar tus proyectos.

Movimiento de los builds fuera del VPS con GitHub Actions
Dokploy puede construir imágenes Docker directamente desde tu repositorio Git, pero en un VPS con 2.5 GB de RAM no es lo ideal.
La solución es construir la imagen en la infraestructura de GitHub. El VPS solo descarga la imagen pre-compilada y la ejecuta.
La arquitectura
git push a main
│
▼
GitHub Actions
├── Build de imagen Docker
├── Push a ghcr.io
├── POST webhook → Dokploy
└── Prune de imágenes viejas (mantiene las últimas 3)
│
▼
Dokploy
├── Recibe webhook
├── Pull de imagen desde GHCR
└── docker compose up -d
El VPS nunca ejecuta bun install ni astro build. Solo hace pull y ejecuta.
Configuración del pipeline
El workflow vive en .github/workflows/pipeline.yml y se dispara con pushes a main (solo cuando cambian archivos relevantes: src/, public/, Dockerfile, package.json, bun.lock, astro.config.mjs o tsconfig.json) o dispatch manual.
name: Deploy Pipeline
on:
push:
branches: [main]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{ is_default_branch }}
type=sha,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Trigger Dokploy Deploy
run: |
curl -X POST "${{ secrets.DOKPLOY_WEBHOOK }}"
- name: Prune old GHCR images
uses: vlaurin/[email protected]
with:
token: ${{ secrets.GHCR_DELETE_TOKEN }}
user: TU_USUARIO_GITHUB
container: TU_REPO
dry-run: false
keep-last: 3
prune-untagged: true
prune-tags-unkept: true
Configuración de Dokploy para hacer pull desde GHCR
En el dashboard de Dokploy:
-
Settings > Registries: Agregá GHCR con tu usuario de GitHub y un Personal Access Token (PAT) con scope
read:packages. -
Tu servicio: Cambiá el proveedor de “Git” a “Docker”. Configurá la imagen como
ghcr.io/TU_USUARIO/TU_REPO:latesty seleccioná el registry que acabás de crear. -
Copiá la URL del webhook desde la pestaña Deployments del servicio.
Secrets de GitHub
Se requieren dos secrets en Settings > Secrets and variables > Actions de tu repositorio:
| Secret | Valor | Propósito |
|---|---|---|
DOKPLOY_WEBHOOK | La URL del webhook de Dokploy | Disparar redeploy después del push |
GHCR_DELETE_TOKEN | PAT con read:packages + delete:packages | Limpiar imágenes viejas de GHCR |
GITHUB_TOKEN se genera automáticamente por Actions en cada ejecución. Tiene permiso packages: write gracias al bloque permissions en el workflow. Es más seguro que un PAT porque solo vive durante la duración del job.
El docker-compose.yml
Dokploy usa este archivo para correr el contenedor:
services:
astro-portfolio:
image: ghcr.io/TU_USUARIO/TU_REPO:latest
pull_policy: always
container_name: astro-portfolio-static
restart: unless-stopped
ports:
- "127.0.0.1:8081:80"
El pull_policy: always asegura que Dokploy siempre descargue la última imagen. El binding de puerto a 127.0.0.1 significa que el contenedor solo es accesible desde localhost. Traefik (corriendo dentro de Dokploy) se encarga del reverse proxy público.
El flujo completo
- Hacés push a
main - GitHub Actions construye la imagen Docker en su infraestructura
- Actions pushea la imagen a
ghcr.io - Actions envía un POST al webhook de Dokploy
- Dokploy recibe el webhook, descarga la nueva imagen y recrea el contenedor
- Actions limpia imágenes viejas de GHCR, manteniendo solo las últimas 3 versiones
- Tu VPS nunca compiló nada. Solo descargó y ejecutó.
Lo que tenés ahora
VPS de $18/año. Sin puertos expuestos públicamente. Actualizaciones de seguridad automáticas. SSH solo por VPN. PaaS self-hosted con HTTPS. Ahora tenés un VPS de producción completo y un PaaS listo para desplegar cualquier proyecto Docker desde su dashboard — incluyendo este portfolio, que puedo seguir actualizando e iterando.