Desplegando Spring Boot en Hetzner Cloud IPv6-only: Lecciones de un experimento curioso

Recientemente me enfrenté a un problema interesante al desplegar una aplicación Spring Boot en un servidor de Hetzner Cloud. Decidí probar algo que me parecía curioso: configurarlo solo con IPv6, sin solicitar la IPv4 adicional.

¿Por qué? Porque me resultó llamativo que Hetzner cobre €0.50/mes extra por cada dirección IPv4 pública, mientras que otros proveedores como Akamai (Linode) y varios competidores ya lo ofrecen de forma gratuita en la mayoría de sus planes. Me dio curiosidad ver hasta dónde se podía llegar hoy en día (enero 2026) desplegando realmente solo con IPv6 en un entorno de producción, y qué dolores de cabeza reales encontraría.

El problema inicial

Al hacer un health check a la aplicación, aparecía este error:

org.eclipse.angus.mail.util.MailConnectException: Couldn't connect to host, port: email-smtp.us-west-2.amazonaws.com, 587; timeout -1

La aplicación Spring Boot no podía conectarse a AWS SES para enviar emails. La configuración era la estándar:

spring.mail.host=email-smtp.us-west-2.amazonaws.com
spring.mail.port=587
spring.mail.username=${AWS_SES_USERNAME}
spring.mail.password=${AWS_SES_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true

Primer diagnóstico: ¿Hetzner bloqueando puertos SMTP?

telnet email-smtp.us-west-2.amazonaws.com 587

Resultado:

Connection failed: Network is unreachable

Hetzner bloquea puertos SMTP (25, 587, 465) por defecto, pero el error “Network is unreachable” ya apuntaba a algo más profundo.

La verdadera causa: IPv6-only

curl -4 ifconfig.me          # Error: no responde
ip addr show

Salida (solo IPv6):

2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP
    inet6 2a01:4ff:f0:e064::1/64 scope global

Y al resolver el dominio de SES:

nslookup email-smtp.us-west-2.amazonaws.com

Solo IPv4:

Name: email-smtp.us-west-2.amazonaws.com
Address: 54.185.234.152
Address: 52.13.3.84
Address: 44.225.148.59

Conclusión: servidor solo IPv6 + servicio solo IPv4 = imposible conectar.

Solución principal: Forzar preferencia IPv6 en Java

El endpoint clásico de AWS SES sí tiene soporte dual-stack (IPv4 + IPv6), pero Java por defecto prefiere IPv4.

Solución definitiva:

java -Djava.net.preferIPv6Addresses=true -jar tu-aplicacion.jar

Comando completo usado:

java -Xmx1g -Xms512m -XX:MaxDirectMemorySize=512m \
     -Djava.net.preferIPv6Addresses=true \
     -Dserver.port=8080 \
     -jar app.jar

Problema secundario: localhost y MariaDB

Error:

Socket fail to connect to localhost. Connection refused

Causa: localhost → ::1 (IPv6), pero MariaDB escucha solo en IPv4.

Solución simple y efectiva:

# Antes
spring.datasource.url=jdbc:mariadb://localhost:3306/mi_base

# Después (recomendado en IPv6-only)
spring.datasource.url=jdbc:mariadb://127.0.0.1:3306/mi_base

Zero Downtime Deployment (Blue-Green simple con puertos)

Estructura:

Nginx (443) → Spring Boot (8080 o 8081)

Script de deployment (restart_api.sh):

#!/bin/bash
set -e

APP_DIR="/home/user/app"
JAR_PATH="$APP_DIR/app-*.jar"
STATE_FILE="$APP_DIR/.current_port"
MAX_HEALTH_RETRIES=30

# Puerto actual
[ -f "$STATE_FILE" ] && CURRENT_PORT=$(cat "$STATE_FILE") || CURRENT_PORT=8080

# Alternar
[ "$CURRENT_PORT" = "8080" ] && NEW_PORT=8081 OLD_PORT=8080 || NEW_PORT=8080 OLD_PORT=8081

echo "Deployment: $OLD_PORT → $NEW_PORT"

# Nueva instancia
screen -S "app-$NEW_PORT" -d -m bash -c \
    "java -Xmx1g -Djava.net.preferIPv6Addresses=true -Dserver.port=$NEW_PORT -jar $JAR_PATH"

# Health check
for i in $(seq 1 $MAX_HEALTH_RETRIES); do
    if curl -f -s "http://localhost:$NEW_PORT/actuator/health" | grep -q '"status":"UP"'; then
        echo "New instance healthy"
        break
    fi
    [ $i -eq $MAX_HEALTH_RETRIES ] && { echo "Health check failed"; screen -S "app-$NEW_PORT" -X quit; exit 1; }
    sleep 2
done

# Actualizar nginx
sudo sed -i "s|proxy_pass http://localhost:[0-9]*;|proxy_pass http://localhost:$NEW_PORT;|" \
    /etc/nginx/sites-available/api.dominio.com
sudo nginx -t && sudo systemctl reload nginx

sleep 5

# Matar vieja
screen -S "app-$OLD_PORT" -X quit 2>/dev/null || true

echo "$NEW_PORT" > "$STATE_FILE"
echo "Deployment completado: puerto activo $NEW_PORT"

Configuración Nginx (dual stack)

server {
    server_name api.dominio.com;
    listen [::]:443 ssl ipv6only=on;
    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/api.dominio.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.dominio.com/privkey.pem;

    location / {
        proxy_pass http://localhost:8080;
        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;
    }
}

Resultados del experimento

  • ✅ Aplicación 100% funcional en IPv6-only
  • ✅ Emails vía AWS SES sin problemas
  • ✅ MariaDB estable
  • ✅ Deployments zero-downtime sencillos

Conclusión

En 2026, desplegar en IPv6-only en Hetzner es totalmente viable y bastante maduro, aunque requiere ajustes puntuales (sobre todo Java + preferencia de direcciones). El mayor “dolor” no fue la falta de IPv6 en servicios, sino el comportamiento por defecto de Java.

¿Vale la pena por ahorrar 0,50€? Probablemente no. ¿Vale la pena como experimento y para estar preparado para el futuro? Totalmente sí.

¿Y tú? ¿Ya tienes algún servicio corriendo puro IPv6? 😄

D3.js – Usando Mapas

En esta entrada de blog veremos como hacer mapas en D3.js y como agregar figuras usando la geolocalización que tengan. Partiremos introduciendo para que sirven los códigos y luego iremos mostrando los resultados esperados.

Promesas

Primero tenemos que partir hablando de las “promesas” en javascript. Viendo la definición en MDN, tenemos que una promesa es un “objeto Promise (Promesa) que es usado para computaciones asíncronas. Una promesa representa un valor que puede estar disponible ahora, en el futuro, o nunca”. En simple, es otra forma de usar callbacks, es decir, sirve para asegurarnos que una acción ocurra después que otra haya terminado.

El siguiente código sirve para poder hacer varias promesas con las funciones de D3.js que leen archivos, tales como d3.json o d3.csv .

var PromiseWrapper = (xhr, d) = &gt; new Promise(resolve = &gt; xhr(d, p =&gt; resolve(p)));

//Nos sirve para poder cargar los archivos y luego ejecutar createMap
     Promise
        .all([
            PromiseWrapper(d3.json, "world.geojson"),
            PromiseWrapper(d3.csv, "cities.csv")
        ])
        .then(resolve =&gt; {
            createMap1(resolve[0], resolve[1]);
        });

Lo que está haciendo, es cargar los 2 archivos solicitados, y una vez que tenga los 2, se resuelve retornando el resultado del wrapper en su orden respectivo, es decir, resolve[0] tiene los datos del archivo world.geojson y resolve[1] tiene los datos de cities.csv. Si se dan cuenta, usamos inmediatamente los resolve como parámetro de la función createMap1. Esta función la crean ustedes.

¿Por qué usamos las promesas aquí? Como dijimos previamente, tienen un rol como el de los callbacks, por lo que estamos buscando hacer que el código se ejecute una vez que contemos con toda la data que necesitamos para trabajar.

GeoJSON

Los archivos GeoJSON, son básicamente json, pero que representan a una geografía. Si en algún momento necesitan el mapa de alguna ciudad o país, tendrían que buscar algo como “chile geojson”, y si tienen suerte, alguien ya hizo el GeoJSON del lugar que buscan. Tengan en mente que si el mapa tiene mucho detalle, será mas pesado, y por lo tanto, será más lenta la visualización que hagan.

Haciendo el Mapa

Cuando se hace un mapa en D3.js, lo primero que tienen que decidir, es que tipo de proyección usarán. Hay que tener en mente que la Tierra es una geoesfera, por lo que hay varias formas de llevarla a un plano. En la documentación pueden encontrar las projections que más les gusten, pero se suele usar geoMercator.

En cuanto al código, lo que hace una proyección es llevar coordenadas de latitud y longitud terrestre, a los puntos en pixeles para poder visualizarlo.

El geoPath contendrá finalmente la figura a dibujar, es decir, el mapa que veremos después.

let ancho=1000;
let projection = d3.geoMercator()
        //que tan cercano
            .scale(200)
            //igual que en los casos pasados
            .translate([ancho / 2, 250])
            //desde el centro, lo podemos mover
            .center([0, 0]);
let geoPath = d3.geoPath().projection(projection);

Lo siguiente no es necesario para hacer un mapa, pero lo haremos para ilustrar algunas cosas que se pueden hacer. Si analizan el GeoJSON, verán que tienen un array llamado “features”, esto es común para todo GeoJSON que lleguen a utilizar. Por simplicidad, usaremos let featureData = countries.features; . Tenemos en cuenta que countries hace referencia al resolve que tiene world.geojson.

       //features es un array con cada pais
        var featureData = countries.features;

        //geoArea nos da el area dado un GeoJson
        //extent nos devuelve el minimo y maximo valor
        //var realFeatureSize = d3.extent(featureData, d =&gt; d3.geoArea(d));
        var realFeatureSize = d3.extent(featureData, function (d) {
            return d3.geoArea(d)
        });
        console.log("Mínimo y Máximo: ");
        console.log(realFeatureSize);

        //hacemos un escala para los colores
        //veamos cual es el rango
        //https://github.com/d3/d3-scale#scaleQuantize
        var newFeatureColor = d3.scaleQuantize()
            .domain(realFeatureSize)
            .range(colorbrewer.OrRd[5]);

La variable realFeatureSize contiene el valor del área más grande y más chica, con esto podemos hacer una escala de colores dependiendo del área de un país.

Como siempre, para finalmente hacer el dibujo, tenemos que usar un “data.enter”.

 //Nada nuevo, aqui hacemos el dibujo
        d3.select("#geoOrthographic1").selectAll("path")
            .data(countries.features).enter()
            .append("path")
            .attr("d", geoPath)
            .attr("id", d =&gt; d.id)
            .attr("class", "countries")
            .style("fill", d =&gt; {
                //AQUI SE USA LA ESCALA DE COLORES EN BASE AL AREA DE CADA PAIS

                if (d.id != "CHL") {
                    return newFeatureColor(d3.geoArea(d))
                } else {
                    return "red";
                }
            })
            .style("stroke", d =&gt; d3.rgb(newFeatureColor(d3.geoArea(d))).darker());

En este ejemplo, ademas hemos intervenido para que cuando se identifique Chile se utilice otro color.

Por último, si se quieren mostrar los meridianos y paralelos lo hacemos con el siguiente código.

        let graticule = d3.geoGraticule();

        //dibujamos la grilla
        d3.select("#geoOrthographic1").insert("path", "path.countries")
            .datum(graticule)
            .attr("class", "graticule line")
            .attr("d", geoPath);

El resultado con lo anterior, debería verse similar a la siguiente imagen.

Agregar Elementos al Mapa

Ahora que ya tenemos un mapa, agregar elementos es como siempre se ha hecho, sólo que para la posición usaremos la proyección que generamos al principio. Ésta nos entrega las coordenadas en pixeles dada una latitud y longitud.

  // dibujamos las ciudades
        d3.select("#geoOrthographic1").selectAll("circle")
            .data(cities).enter()
            .append("circle")
            .attr("class", "cities")
            .attr("r", 10)
            .attr("cx", d =&gt; projection([d.x, d.y])[0])
            .attr("cy", d =&gt; projection([d.x, d.y])[1]);

        //dibujamos los textos
        d3.select("#geoOrthographic1").selectAll("text")
            .data(cities).enter()
            .append("text")
            .attr("x", d =&gt; projection([d.x, d.y])[0])
            .attr("y", d =&gt; projection([d.x, d.y])[1])
            .style("font-size", "20px")
            .style("font-family", "sans-serif")
            .style("fill", "black")
            .text(d =&gt; {
                return d.label
            });

        //dibujamos charmanders
        d3.select("#geoOrthographic1").selectAll("images")
            .data(cities).enter()
            .append("svg:image")
            .attr("xlink:href", "pokemon_PNG154.png")
            .attr("x", d =&gt; projection([d.x, d.y])[0])
            .attr("y", d =&gt; projection([d.x, d.y])[1])
            .attr('width', 100)
            .attr('height', 100)

El resultado final con los elementos agregados debería ser como la siguiente imagen.

Código Fuente

Los códigos vistos aquí son una adaptación de los hechos por Elijah Meeks. Los códigos adaptados pueden ser descargados aquí. También pueden ver un código algo más complejo aquí, encontran un mapa muy similar al visto en esta entrada de blog, otro donde se incorpora una representación en 3 dimensiones, y un último en el que se implementa un zoom al mapa.