--- title: "Shopify headless con backend Node.js en Ubuntu 24.04" description: "Crea un backend Node.js en un VPS Ubuntu 24.04 que consume la Storefront API de Shopify, gestiona carritos, verifica webhooks y se publica tras Nginx con HTTPS." category: "E-commerce y CMS" language: es source: https://cubepath.com/es/docs/e-commerce-y-cms/shopify-headless-con-backend-vps-personalizado updated: 2026-09-25 --- # Shopify headless con backend Node.js en Ubuntu 24.04 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: | Capa | Responsabilidad | Dónde se ejecuta | |---|---|---| | Frontend | Páginas de producto, carrito, diseño | Donde quieras (Next.js, Hydrogen, una app móvil) | | Backend propio | Token privado, caché, reglas de negocio, webhooks | Tu VPS, en `https://api.example.com` | | Shopify | Catálogo, stock, checkout, pagos, pedidos | Infraestructura 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**. > Importante: el token privado solo debe vivir en el servidor. No lo incluyas nunca en el código del frontend ni en un repositorio. 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`: ```bash 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: ```output {"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: ```bash 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: ```bash 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: ```bash node --version npm --version ``` ```output 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: ```bash 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`: ```bash 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: ```bash sudo -u shopify -H npm --prefix /opt/shopify-bff install express@5 ``` ```output 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: ```bash sudo nano /etc/shopify-bff.env ``` Añade estas variables con tus valores: ```ini 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: ```bash 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: ```bash sudo -u shopify nano /opt/shopify-bff/server.js ``` Pega el siguiente código: ```javascript 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: ```bash sudo nano /etc/systemd/system/shopify-bff.service ``` ```ini [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: ```bash sudo systemctl daemon-reload sudo systemctl enable --now shopify-bff ``` Comprueba que está activo: ```bash sudo systemctl status shopify-bff ``` ```output ● 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: ```bash curl -s http://127.0.0.1:3000/api/products?first=2 ``` ```output [{"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é: ```bash sudo install -d -o www-data -g www-data /var/cache/nginx/shopify ``` Crea el bloque de servidor: ```bash sudo nano /etc/nginx/sites-available/api.example.com ``` ```nginx 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: ```bash sudo ln -s /etc/nginx/sites-available/api.example.com /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx ``` ```output 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: ```bash 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: ```bash 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 ``` ```output 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: ```bash 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: ```bash sudo journalctl -u shopify-bff -n 5 ``` ```output 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: ```bash 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}' ``` ```output 401 ``` > Nota: los webhooks creados desde **Settings > Notifications** se firman con la clave de la tienda. Si más adelante los registras desde una app de Shopify, la firma se calcula con el *client secret* de esa app. ## 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`: ```bash curl -s -X POST https://api.example.com/api/cart \ -H "Content-Type: application/json" \ -d '{"lines":[{"merchandiseId":"gid://shopify/ProductVariant/45123456789","quantity":1}]}' ``` ```output {"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`.