En una tienda Shopify headless, Shopify sigue gestionando el catálogo, el inventario, los pagos y los pedidos, pero la parte visible de la tienda la construyes tú con el framework que prefieras. Entre ese frontend y Shopify conviene tener un backend propio (un backend for frontend) que guarde los tokens privados, cachee el catálogo y reciba los webhooks. En este tutorial montarás ese backend con Node.js y Express en Ubuntu 24.04, lo ejecutarás como servicio de systemd y lo publicarás tras Nginx con HTTPS y caché.

Requisitos previos

Para seguir esta guía necesitas:

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath con 1 GB de RAM como mínimo.
  • Un usuario no root con privilegios sudo.
  • Nginx instalado y los puertos 80 y 443 abiertos en el firewall (sudo ufw allow 'Nginx Full').
  • Un subdominio para la API, por ejemplo api.example.com, con un registro DNS A que apunte a la IP del servidor. En los ejemplos sustituye api.example.com por tu dominio.
  • Una tienda Shopify (de pago o una tienda de desarrollo creada desde tu cuenta de Shopify Partners) con al menos un producto publicado.

Cómo encaja cada pieza

La arquitectura que vas a montar tiene tres capas:

CapaResponsabilidadDónde se ejecuta
FrontendPáginas de producto, carrito, diseñoDonde quieras (Next.js, Hydrogen, una app móvil)
Backend propioToken privado, caché, reglas de negocio, webhooksTu VPS, en https://api.example.com
ShopifyCatálogo, stock, checkout, pagos, pedidosInfraestructura de Shopify

El navegador nunca habla con Shopify con credenciales privadas: llama a tu backend, y este consulta la Storefront API (GraphQL). El pago se completa en el checkout de Shopify, al que el backend redirige con la URL checkoutUrl del carrito.

Paso 1: Obtener los tokens de la Storefront API

Shopify recomienda el canal de ventas Headless para las tiendas desacopladas, porque genera un token público (para usar desde el navegador) y uno privado (para usar desde un servidor).

  1. En el panel de Shopify, abre la Shopify App Store e instala el canal de ventas Headless.
  2. Dentro del canal, pulsa Create storefront.
  3. En Storefront API permissions, comprueba que están activados los permisos de lectura de productos y colecciones y los de lectura y escritura de checkouts, que son los que usa la Cart API.
  4. En Manage API access, copia el Private access token.

Antes de escribir código, comprueba desde el VPS que el token funciona. Sustituye your-store, your_private_token y la versión de la API por los tuyos. Shopify publica una versión de la API cada trimestre (formato AAAA-MM) y mantiene cada una al menos 12 meses; usa la versión estable más reciente que aparezca en la documentación de la Storefront API. En esta guía se usa 2026-04:

curl -s https://your-store.myshopify.com/api/2026-04/graphql.json \
  -H "Content-Type: application/json" \
  -H "Shopify-Storefront-Private-Token: your_private_token" \
  -d '{"query":"{ shop { name } products(first: 2) { nodes { title handle } } }"}'

Si todo es correcto obtendrás el nombre de la tienda y dos productos:

{"data":{"shop":{"name":"Mi tienda"},"products":{"nodes":[{"title":"Camiseta básica","handle":"camiseta-basica"},{"title":"Taza","handle":"taza"}]}}}

Un error 401 o 403 indica que el token es incorrecto o que falta algún permiso en el canal Headless.

Paso 2: Instalar Node.js

Ubuntu 24.04 incluye Node.js 18, que ya no recibe soporte. Instala la versión LTS actual desde el repositorio oficial de NodeSource. Primero, añade su clave de firma:

sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg

Añade el repositorio de Node.js 24 e instala el paquete:

echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt update
sudo apt install -y nodejs

Comprueba las versiones instaladas:

node --version
npm --version
v24.8.0
11.6.0

Los números exactos pueden variar; lo importante es que la versión principal de Node.js sea la 24.

Paso 3: Crear el usuario y el proyecto

El backend se ejecutará con un usuario de sistema sin shell ni privilegios, de modo que un fallo en la aplicación no comprometa el resto del servidor:

sudo useradd --system --create-home --home-dir /opt/shopify-bff --shell /usr/sbin/nologin shopify

Crea el archivo package.json como ese usuario. El campo "type": "module" permite usar la sintaxis import:

sudo -u shopify tee /opt/shopify-bff/package.json > /dev/null <<'EOF'
{
  "name": "shopify-bff",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "main": "server.js"
}
EOF

Instala Express 5, la única dependencia. Node.js 24 ya incluye fetch y crypto, así que no hace falta instalar clientes HTTP adicionales:

sudo -u shopify -H npm --prefix /opt/shopify-bff install express@5
added 66 packages, and audited 67 packages in 3s

found 0 vulnerabilities

Paso 4: Guardar la configuración en un archivo de entorno

Los secretos se guardan fuera del código, en un archivo que solo puede leer root. systemd lo cargará al arrancar el servicio:

sudo nano /etc/shopify-bff.env

Añade estas variables con tus valores:

SHOPIFY_STORE_DOMAIN=your-store.myshopify.com
SHOPIFY_API_VERSION=2026-04
SHOPIFY_STOREFRONT_PRIVATE_TOKEN=your_private_token
SHOPIFY_WEBHOOK_SECRET=your_webhook_secret
ALLOWED_ORIGIN=https://www.example.com
PORT=3000

ALLOWED_ORIGIN es el dominio de tu frontend, al que se permitirá llamar a la API desde el navegador. SHOPIFY_WEBHOOK_SECRET lo obtendrás en el paso 8; de momento deja el valor de ejemplo.

Restringe los permisos del archivo:

sudo chmod 600 /etc/shopify-bff.env

Paso 5: Escribir el backend

El servidor expone cuatro rutas:

  • GET /api/products: listado de productos.
  • GET /api/products/:handle: detalle de un producto con sus variantes.
  • POST /api/cart: crea un carrito y devuelve la URL de checkout de Shopify.
  • POST /webhooks/shopify: recibe webhooks y verifica su firma HMAC.

Crea el archivo:

sudo -u shopify nano /opt/shopify-bff/server.js

Pega el siguiente código:

import crypto from 'node:crypto';
import express from 'express';

const {
  SHOPIFY_STORE_DOMAIN,
  SHOPIFY_API_VERSION,
  SHOPIFY_STOREFRONT_PRIVATE_TOKEN,
  SHOPIFY_WEBHOOK_SECRET,
  ALLOWED_ORIGIN,
  PORT = '3000',
} = process.env;

const STOREFRONT_URL = `https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`;

// Llama a la Storefront API con el token privado y reenvía la IP del comprador,
// que Shopify usa para su protección contra bots y abuso.
async function storefront(query, variables, buyerIp) {
  const headers = {
    'Content-Type': 'application/json',
    'Shopify-Storefront-Private-Token': SHOPIFY_STOREFRONT_PRIVATE_TOKEN,
  };
  if (buyerIp) headers['Shopify-Storefront-Buyer-IP'] = buyerIp;

  const response = await fetch(STOREFRONT_URL, {
    method: 'POST',
    headers,
    body: JSON.stringify({ query, variables }),
    signal: AbortSignal.timeout(10_000),
  });
  if (!response.ok) {
    throw new Error(`Storefront API HTTP ${response.status}`);
  }
  const body = await response.json();
  if (body.errors) {
    throw new Error(body.errors.map((e) => e.message).join('; '));
  }
  return body.data;
}

const PRODUCTS_QUERY = `
  query Products($first: Int!) {
    products(first: $first, sortKey: UPDATED_AT, reverse: true) {
      nodes {
        id
        title
        handle
        featuredImage { url altText }
        priceRange { minVariantPrice { amount currencyCode } }
      }
    }
  }`;

const PRODUCT_QUERY = `
  query Product($handle: String!) {
    product(handle: $handle) {
      id
      title
      descriptionHtml
      images(first: 10) { nodes { url altText } }
      variants(first: 50) {
        nodes {
          id
          title
          availableForSale
          price { amount currencyCode }
        }
      }
    }
  }`;

const CART_CREATE = `
  mutation CartCreate($lines: [CartLineInput!]!) {
    cartCreate(input: { lines: $lines }) {
      cart {
        id
        checkoutUrl
        cost { totalAmount { amount currencyCode } }
      }
      userErrors { field message }
    }
  }`;

const app = express();
app.disable('x-powered-by');
// Nginx corre en la misma máquina: confía en su X-Forwarded-For para obtener la IP real.
app.set('trust proxy', 'loopback');

// CORS: solo el dominio del frontend puede llamar a /api desde el navegador.
app.use('/api', (req, res, next) => {
  res.set('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
  res.set('Vary', 'Origin');
  if (req.method === 'OPTIONS') {
    res.set('Access-Control-Allow-Methods', 'GET, POST');
    res.set('Access-Control-Allow-Headers', 'Content-Type');
    return res.sendStatus(204);
  }
  next();
});

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/api/products', async (req, res) => {
  const first = Math.min(Number.parseInt(req.query.first, 10) || 20, 50);
  const data = await storefront(PRODUCTS_QUERY, { first }, req.ip);
  res.set('Cache-Control', 'public, max-age=60');
  res.json(data.products.nodes);
});

app.get('/api/products/:handle', async (req, res) => {
  const { handle } = req.params;
  if (!/^[a-z0-9-]{1,255}$/i.test(handle)) {
    return res.status(400).json({ detail: 'Handle no válido' });
  }
  const data = await storefront(PRODUCT_QUERY, { handle }, req.ip);
  if (!data.product) {
    return res.status(404).json({ detail: 'Producto no encontrado' });
  }
  res.set('Cache-Control', 'public, max-age=60');
  res.json(data.product);
});

app.post('/api/cart', express.json({ limit: '10kb' }), async (req, res) => {
  const lines = Array.isArray(req.body?.lines) ? req.body.lines : [];
  const valid = lines.length > 0 && lines.length <= 50 && lines.every(
    (l) => typeof l.merchandiseId === 'string' &&
      l.merchandiseId.startsWith('gid://shopify/ProductVariant/') &&
      Number.isInteger(l.quantity) && l.quantity > 0 && l.quantity <= 99,
  );
  if (!valid) {
    return res.status(400).json({ detail: 'Líneas de carrito no válidas' });
  }

  const data = await storefront(CART_CREATE, { lines }, req.ip);
  const { cart, userErrors } = data.cartCreate;
  if (userErrors.length > 0) {
    return res.status(422).json({ detail: userErrors.map((e) => e.message).join('; ') });
  }
  res.set('Cache-Control', 'no-store');
  res.status(201).json(cart);
});

// Los webhooks se firman sobre el cuerpo exacto: hay que leerlo sin parsear.
app.post('/webhooks/shopify', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
  const received = Buffer.from(req.get('X-Shopify-Hmac-Sha256') ?? '', 'base64');
  const expected = crypto
    .createHmac('sha256', SHOPIFY_WEBHOOK_SECRET)
    .update(req.body)
    .digest();

  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return res.sendStatus(401);
  }

  const topic = req.get('X-Shopify-Topic');
  const payload = JSON.parse(req.body.toString('utf8'));
  console.log(`Webhook ${topic} recibido para el recurso ${payload.id}`);
  // Aquí iría tu lógica: sincronizar un buscador, avisar a un ERP, etc.
  res.sendStatus(200);
});

// Express 5 envía aquí los errores de las rutas async.
app.use((err, req, res, next) => {
  console.error(err);
  res.status(502).json({ detail: 'No se pudo contactar con Shopify' });
});

app.listen(Number(PORT), '127.0.0.1', () => {
  console.log(`shopify-bff escuchando en 127.0.0.1:${PORT}`);
});

Algunas decisiones del código:

  • Usa la Cart API (cartCreate y checkoutUrl). La antigua Checkout API (checkoutCreate) está retirada de la Storefront API, así que el código que todavía la use dejará de funcionar.
  • Usa product(handle:) en lugar del obsoleto productByHandle.
  • El servidor escucha solo en 127.0.0.1: únicamente Nginx puede llegar a él.
  • La firma de los webhooks se compara con crypto.timingSafeEqual para no filtrar información por tiempo de respuesta.

Paso 6: Ejecutar el backend con systemd

Crea una unidad de systemd para que el servicio arranque con el sistema y se reinicie si falla:

sudo nano /etc/systemd/system/shopify-bff.service
[Unit]
Description=Backend headless de Shopify
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=shopify
Group=shopify
WorkingDirectory=/opt/shopify-bff
EnvironmentFile=/etc/shopify-bff.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target

Recarga systemd, habilita el servicio e inícialo:

sudo systemctl daemon-reload
sudo systemctl enable --now shopify-bff

Comprueba que está activo:

sudo systemctl status shopify-bff
● shopify-bff.service - Backend headless de Shopify
     Loaded: loaded (/etc/systemd/system/shopify-bff.service; enabled; preset: enabled)
     Active: active (running) since Thu 2026-09-25 10:20:14 UTC; 5s ago

Prueba la API directamente en el puerto local:

curl -s http://127.0.0.1:3000/api/products?first=2
[{"id":"gid://shopify/Product/8123456789","title":"Camiseta básica","handle":"camiseta-basica","featuredImage":{"url":"https://cdn.shopify.com/s/files/...","altText":null},"priceRange":{"minVariantPrice":{"amount":"19.9","currencyCode":"EUR"}}}, ...]

Si obtienes un error, revisa el registro del servicio con sudo journalctl -u shopify-bff -n 50.

Paso 7: Publicar la API con Nginx y HTTPS

Nginx hará de proxy inverso, terminará TLS y cacheará durante 60 segundos las respuestas del catálogo, de modo que una avalancha de visitas no se convierta en una avalancha de peticiones a Shopify. Crea el directorio de caché:

sudo install -d -o www-data -g www-data /var/cache/nginx/shopify

Crea el bloque de servidor:

sudo nano /etc/nginx/sites-available/api.example.com
proxy_cache_path /var/cache/nginx/shopify levels=1:2 keys_zone=shopify:10m max_size=200m inactive=10m use_temp_path=off;

server {
    listen 80;
    listen [::]:80;
    server_name api.example.com;

    client_max_body_size 1m;

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

    # Solo se cachean las lecturas del catálogo.
    location /api/products {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_cache shopify;
        proxy_cache_valid 200 60s;
        proxy_cache_use_stale error timeout updating http_502;
        proxy_cache_lock on;
        add_header X-Cache-Status $upstream_cache_status always;
    }
}

proxy_cache_use_stale hace que, si Shopify no responde, Nginx siga sirviendo la última copia del catálogo en lugar de un error. Activa el sitio, comprueba la sintaxis y recarga Nginx:

sudo ln -s /etc/nginx/sites-available/api.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Obtén un certificado de Let's Encrypt con Certbot. El plugin de Nginx añade el bloque HTTPS y la redirección desde HTTP:

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.com

Comprueba la caché haciendo la misma petición dos veces:

curl -s -o /dev/null -D - https://api.example.com/api/products | grep -i x-cache-status
curl -s -o /dev/null -D - https://api.example.com/api/products | grep -i x-cache-status
x-cache-status: MISS
x-cache-status: HIT

Paso 8: Configurar los webhooks

Los webhooks permiten reaccionar a cambios en Shopify (un producto actualizado, un pedido pagado) sin consultar la API continuamente.

  1. En el panel de Shopify, ve a Settings > Notifications > Webhooks.
  2. Pulsa Create webhook, elige el evento (por ejemplo Product update), formato JSON y como URL https://api.example.com/webhooks/shopify.
  3. Debajo de la lista, Shopify muestra la clave con la que firma estos webhooks. Cópiala.

Pon esa clave en SHOPIFY_WEBHOOK_SECRET del archivo /etc/shopify-bff.env y reinicia el servicio:

sudo nano /etc/shopify-bff.env
sudo systemctl restart shopify-bff

Pulsa Send test junto al webhook en el panel de Shopify y mira el registro del servicio:

sudo journalctl -u shopify-bff -n 5
Sep 25 10:41:02 vps node[2231]: Webhook products/update recibido para el recurso 788032119674292922

Comprueba también que una petición sin firma válida se rechaza:

curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.example.com/webhooks/shopify \
  -H "Content-Type: application/json" -d '{"id":1}'
401

Paso 9: Consumir la API desde el frontend

Tu frontend ya puede usar la API como cualquier otra. Crea un carrito con una variante; el merchandiseId es el id de la variante que devuelve GET /api/products/:handle:

curl -s -X POST https://api.example.com/api/cart \
  -H "Content-Type: application/json" \
  -d '{"lines":[{"merchandiseId":"gid://shopify/ProductVariant/45123456789","quantity":1}]}'
{"id":"gid://shopify/Cart/Z2NwLWV1cm9wZS13ZXN0NDox...","checkoutUrl":"https://your-store.myshopify.com/cart/c/Z2NwLWV1cm9wZS13ZXN0NDox...","cost":{"totalAmount":{"amount":"19.9","currencyCode":"EUR"}}}

En el navegador, el flujo de compra es:

  1. El frontend llama a POST /api/cart con las líneas del carrito.
  2. Guarda el id del carrito (por ejemplo en una cookie) para poder añadir líneas más tarde.
  3. Cuando el cliente pulsa Pagar, redirige a checkoutUrl. Shopify se encarga del pago y del pedido.

Si prefieres no mantener tu propio frontend desde cero, Hydrogen, el framework de Shopify basado en React Router, trae componentes de carrito y producto listos, y puede convivir con este backend para la lógica propia (webhooks, integraciones, reglas de negocio).

Solución de problemas

  • 502 con No se pudo contactar con Shopify: revisa sudo journalctl -u shopify-bff. Un Storefront API HTTP 401 indica token incorrecto; un error de versión indica que SHOPIFY_API_VERSION ya no está soportada y hay que actualizarla.
  • Errores CORS en el navegador: ALLOWED_ORIGIN debe coincidir exactamente con el origen del frontend, incluido https:// y sin barra final.
  • Los webhooks siempre dan 401: la clave no coincide o algo modifica el cuerpo antes de llegar a Express. Comprueba que no hay otra capa (un WAF o un proxy) que reescriba el JSON.
  • Cambios de precio que tardan en verse: es la caché de 60 segundos de Nginx. Reduce proxy_cache_valid si necesitas precios en tiempo real.

Conclusión

Has montado un backend propio para una tienda Shopify headless: guarda el token privado en el servidor, sirve el catálogo con caché, crea carritos con la Cart API y verifica los webhooks con HMAC, todo ello como servicio de systemd tras Nginx con HTTPS. A partir de aquí puedes añadir actualizaciones de carrito con cartLinesAdd, invalidar la caché de Nginx al recibir un webhook products/update o desplegar tu frontend Next.js o Hydrogen apuntando a https://api.example.com.