In a headless Shopify setup, Shopify keeps handling products, inventory, payments and checkout, while you build the storefront with any technology you like. Your frontend talks to Shopify through the Storefront API, a GraphQL API designed for public buyer-facing experiences. In this tutorial you will build a small Node.js backend on Ubuntu 24.04 that queries products and creates carts with a private Storefront API token, caches the catalog, and runs as a systemd service behind Nginx with HTTPS. Any frontend (React, Vue, a static site or a mobile app) can then consume it.
Why put a backend between the frontend and Shopify
You can call the Storefront API directly from the browser with a public token. A backend on your own server is useful when you want to:
- Keep a private Storefront API token on the server, which gets higher rate limits than public tokens and is never exposed to browsers.
- Combine Shopify data with your own data (reviews, ERP stock, custom content) in a single response.
- Cache catalog responses close to your frontend and control exactly which fields are exposed.
Checkout itself stays on Shopify: the backend creates a cart and returns its checkoutUrl, and the buyer completes payment on Shopify's hosted checkout.
Prerequisites
To follow this guide you need:
- A Shopify store on a plan that allows sales channels, with at least one active product.
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with 1 GB of RAM or more.
- A non-root user with
sudoprivileges. - A subdomain such as
api.your_domainwith a DNS A record pointing toyour_server_ip.
Step 1 - Creating a private Storefront API token
Shopify issues Storefront API tokens through the Headless sales channel.
- In the Shopify admin, open the Shopify App Store and install the Headless channel from Shopify.
- In Sales channels > Headless, click Create storefront.
- Open the storefront, go to Storefront API and, under Permissions, check that it can read products, collections and inventory, and write carts (
unauthenticated_read_product_listings,unauthenticated_read_product_inventory,unauthenticated_write_checkoutsand related scopes). Save any change. - Copy the Private access token. Also note your store domain in the form
your-store.myshopify.com.
ImportantTreat the private token like a password. It must only live on your server, never in frontend code or in a Git repository.
Shopify releases a new Storefront API version every quarter (for example 2026-07) and supports each one for at least 12 months. This guide uses 2026-07; pick a currently supported version from Shopify's API versioning documentation and update it at least once a year.
Step 2 - Installing Node.js
Install the current Node.js LTS release from the NodeSource repository, which provides up-to-date packages for Ubuntu. Download the repository key:
sudo apt update
sudo apt install ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
Add the repository for Node.js 24 and install it:
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 > /dev/null
sudo apt update
sudo apt install nodejs
Verify the installation:
node --version
npm --version
v24.8.0
11.6.0
Node.js 18 and later include a native fetch, so the backend does not need an HTTP client library.
Step 3 - Creating the project
Run the service under its own system user without a login shell, so a bug in the application cannot touch the rest of the server:
sudo useradd --system --home /opt/shop-api --shell /usr/sbin/nologin shopapi
sudo mkdir -p /opt/shop-api
sudo chown your_user:your_user /opt/shop-api
cd /opt/shop-api
Initialize the project and install Express:
npm init -y
npm pkg set type=module
npm install express
type=module lets you use import syntax in server.js.
Step 4 - Writing the backend
Create the application file:
nano /opt/shop-api/server.js
Paste the following code. It exposes three endpoints: a product list, a single product by handle, and cart creation.
import express from 'express';
const {
SHOPIFY_STORE_DOMAIN,
SHOPIFY_STOREFRONT_PRIVATE_TOKEN,
SHOPIFY_API_VERSION = '2026-07',
PORT = '3000',
CACHE_TTL_SECONDS = '60',
} = process.env;
if (!SHOPIFY_STORE_DOMAIN || !SHOPIFY_STOREFRONT_PRIVATE_TOKEN) {
console.error('SHOPIFY_STORE_DOMAIN and SHOPIFY_STOREFRONT_PRIVATE_TOKEN are required');
process.exit(1);
}
const endpoint = `https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`;
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 res = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`Storefront API returned HTTP ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(body.errors.map((e) => e.message).join('; '));
return body.data;
}
const PRODUCT_FIELDS = `
id
handle
title
availableForSale
featuredImage { url altText }
priceRange { minVariantPrice { amount currencyCode } }
`;
const PRODUCTS_QUERY = `
query Products($first: Int!) {
products(first: $first, sortKey: BEST_SELLING) {
nodes { ${PRODUCT_FIELDS} }
}
}
`;
const PRODUCT_QUERY = `
query Product($handle: String!) {
product(handle: $handle) {
${PRODUCT_FIELDS}
descriptionHtml
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 }
}
}
`;
// Small in-memory cache for catalog reads.
const cache = new Map();
async function cached(key, loader) {
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value;
const value = await loader();
cache.set(key, { value, expires: Date.now() + Number(CACHE_TTL_SECONDS) * 1000 });
return value;
}
const app = express();
app.set('trust proxy', 'loopback');
app.use(express.json({ limit: '20kb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.get('/api/products', async (req, res) => {
const data = await cached('products', () => storefront(PRODUCTS_QUERY, { first: 24 }, req.ip));
res.json(data.products.nodes);
});
app.get('/api/products/:handle', async (req, res) => {
const { handle } = req.params;
const data = await cached(`product:${handle}`, () => storefront(PRODUCT_QUERY, { handle }, req.ip));
if (!data.product) return res.status(404).json({ error: 'Product not found' });
res.json(data.product);
});
app.post('/api/cart', 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' && Number.isInteger(l.quantity) && l.quantity > 0 && l.quantity <= 100,
);
if (!valid) return res.status(400).json({ error: 'Invalid cart lines' });
const data = await storefront(CART_CREATE, { lines }, req.ip);
const { cart, userErrors } = data.cartCreate;
if (userErrors.length) return res.status(422).json({ errors: userErrors });
res.status(201).json(cart);
});
app.use((err, req, res, next) => {
console.error(err.message);
res.status(502).json({ error: 'Upstream error' });
});
app.listen(Number(PORT), '127.0.0.1', () => {
console.log(`shop-api listening on 127.0.0.1:${PORT}`);
});
A few design points:
- The token is read from environment variables, never hardcoded.
- The
Shopify-Storefront-Buyer-IPheader forwards the buyer's IP. Shopify requires it with private tokens so that its bot protection and rate limiting apply per buyer instead of treating all traffic as coming from your server.trust proxymakesreq.ipuse the address Nginx forwards. - Only catalog reads are cached. Carts are always created live.
- Express 5, which
npm install expressinstalls, forwards errors thrown inasynchandlers to the error middleware, so a Shopify outage returns a clean 502. - The server listens on
127.0.0.1only. Nginx is the public entry point.
Step 5 - Storing the credentials
Put the configuration in a file only root can read. systemd will load it when it starts the service:
sudo nano /etc/shop-api.env
SHOPIFY_STORE_DOMAIN=your-store.myshopify.com
SHOPIFY_STOREFRONT_PRIVATE_TOKEN=your_private_token
SHOPIFY_API_VERSION=2026-07
PORT=3000
CACHE_TTL_SECONDS=60
Restrict its permissions:
sudo chmod 600 /etc/shop-api.env
Test the application once in the foreground, loading the same file:
cd /opt/shop-api
sudo bash -c 'set -a; . /etc/shop-api.env; set +a; exec node server.js'
In a second terminal, request the product list:
curl -s http://127.0.0.1:3000/api/products | head -c 300
[{"id":"gid://shopify/Product/8123456789","handle":"blue-shirt","title":"Blue shirt","availableForSale":true,"featuredImage":{"url":"https://cdn.shopify.com/s/files/...
If you get {"error":"Upstream error"}, the first terminal prints the reason, usually a wrong domain, token or API version. Stop the test server with Ctrl+C.
Step 6 - Running the backend with systemd
Hand the project over to the service user:
sudo chown -R shopapi:shopapi /opt/shop-api
Create the unit file:
sudo nano /etc/systemd/system/shop-api.service
[Unit]
Description=Headless Shopify backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=shopapi
Group=shopapi
WorkingDirectory=/opt/shop-api
EnvironmentFile=/etc/shop-api.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/node /opt/shop-api/server.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EnvironmentFile is read by systemd as root before dropping privileges, so the shopapi user never needs read access to /etc/shop-api.env. The Protect* options make the file system read-only for the process, which is fine because it writes nothing to disk.
Start the service and enable it at boot:
sudo systemctl daemon-reload
sudo systemctl enable --now shop-api
systemctl status shop-api --no-pager
● shop-api.service - Headless Shopify backend
Loaded: loaded (/etc/systemd/system/shop-api.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-09-24 10:12:03 UTC; 3s ago
Application logs go to the journal:
sudo journalctl -u shop-api -f
Step 7 - Publishing the API with Nginx and HTTPS
Install Nginx and Certbot:
sudo apt install nginx certbot python3-certbot-nginx
Define a rate limit zone for cart creation, which is the only endpoint that writes to Shopify:
sudo nano /etc/nginx/conf.d/shop-api-ratelimit.conf
limit_req_zone $binary_remote_addr zone=shop_cart:10m rate=10r/m;
Create the server block:
sudo nano /etc/nginx/sites-available/shop-api
server {
listen 80;
listen [::]:80;
server_name api.your_domain;
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;
}
location = /api/cart {
limit_req zone=shop_cart burst=5 nodelay;
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;
}
}
Enable the site and reload:
sudo ln -s /etc/nginx/sites-available/shop-api /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Open the firewall and request the certificate:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo certbot --nginx -d api.your_domain
Step 8 - Testing the full flow
Fetch a product with its variants over HTTPS. Replace blue-shirt with the handle of one of your products:
curl -s https://api.your_domain/api/products/blue-shirt
Copy the id of a variant from the variants.nodes list, which looks like gid://shopify/ProductVariant/44123456789, and create a cart with it:
curl -s -X POST https://api.your_domain/api/cart \
-H "Content-Type: application/json" \
-d '{"lines":[{"merchandiseId":"gid://shopify/ProductVariant/44123456789","quantity":1}]}'
{"id":"gid://shopify/Cart/Z2NwLWV1cm9wZS13ZXN0NDowMUo...","checkoutUrl":"https://your-store.myshopify.com/cart/c/Z2NwLWV1cm9wZS13ZXN0NDowMUo...","cost":{"totalAmount":{"amount":"29.0","currencyCode":"EUR"}}}
Open the checkoutUrl in a browser: you should land on Shopify's checkout with the product in the cart. Your frontend does the same thing: it calls /api/cart when the buyer clicks Checkout and redirects to the returned URL.
If your frontend runs on a different domain, the browser will block its requests unless the API sends CORS headers. Allow only your storefront's origin, for example by installing the cors package and adding app.use(cors({ origin: 'https://your_domain' })) before the routes.
Troubleshooting
Every request returns Upstream error and the journal shows HTTP 401 or HTTP 403. The token is wrong, was revoked, or belongs to another store. Check SHOPIFY_STORE_DOMAIN uses the myshopify.com domain, then restart with sudo systemctl restart shop-api.
The journal shows HTTP 404. The API version in SHOPIFY_API_VERSION does not exist or is no longer supported. Use a current quarterly version.
cartCreate returns userErrors saying the merchandise does not exist. You passed a product ID instead of a variant ID, or the product is not published to the Headless channel. Check the product's Sales channels in the Shopify admin.
Changes in Shopify take a minute to appear. That is the in-memory cache. Lower CACHE_TTL_SECONDS or restart the service to clear it.
Conclusion
You now have a headless Shopify backend on your own Ubuntu 24.04 server: it keeps the private Storefront API token server-side, caches catalog reads, creates carts that hand off to Shopify's checkout, and runs under systemd behind Nginx with HTTPS and rate limiting. As next steps, build your frontend on top of these endpoints, add collection and search endpoints with the corresponding Storefront API queries, and replace the in-memory cache with Redis if you run more than one instance.
