<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DeployHQ</title>
    <description>The latest articles on DEV Community by DeployHQ (@deployhq).</description>
    <link>https://dev.to/deployhq</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1687924%2F3c97db5e-145a-4aae-adbe-b57f149a6ec3.png</url>
      <title>DEV Community: DeployHQ</title>
      <link>https://dev.to/deployhq</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deployhq"/>
    <language>en</language>
    <item>
      <title>Self-Host n8n on a VPS: Docker, HTTPS, and Git-Based Updates</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Thu, 21 May 2026 09:40:22 +0000</pubDate>
      <link>https://dev.to/deployhq/self-host-n8n-on-a-vps-docker-https-and-git-based-updates-2k9k</link>
      <guid>https://dev.to/deployhq/self-host-n8n-on-a-vps-docker-https-and-git-based-updates-2k9k</guid>
      <description>&lt;p&gt;Most n8n self-hosting tutorials stop at &lt;code&gt;docker compose up&lt;/code&gt;. That gets you a running instance, but it leaves a real gap: where do your workflows live? How do you upgrade without losing them? How do you roll back a bad change? Treat your n8n server like any other application — workflows in Git, infrastructure in Docker Compose, deploys triggered from &lt;code&gt;main&lt;/code&gt; — and self-hosting becomes a maintainable habit instead of a fragile pet project.&lt;/p&gt;

&lt;p&gt;This guide walks through standing up n8n on a single VPS with Docker, putting it behind HTTPS, and wiring it to a &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; project so every change to your workflow repo updates the server. By the end you'll have a production-ready instance with backups, version control, and one-click rollback when something goes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;n8n&lt;/strong&gt; running in Docker on a single Ubuntu VPS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres&lt;/strong&gt; alongside it as the data store (not SQLite — more on that below)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx + Let's Encrypt&lt;/strong&gt; in front for HTTPS on your own domain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Git repository&lt;/strong&gt; that holds the &lt;code&gt;docker-compose.yml&lt;/code&gt;, environment template, and an exportable JSON copy of every workflow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeployHQ&lt;/strong&gt; wiring the repo to the server, so a &lt;code&gt;git push&lt;/code&gt; to &lt;code&gt;main&lt;/code&gt; reconfigures the stack and re-imports workflows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end you'll be able to develop a workflow locally (or on a staging instance), export it, commit, push, and watch &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deploy the change with rollback available if it misbehaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why self-host n8n at all?
&lt;/h2&gt;

&lt;p&gt;n8n Cloud is good. So why bother with a VPS?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost predictability.&lt;/strong&gt; A $5-10/month VPS handles thousands of runs per day. Cloud pricing scales by execution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data stays on your infrastructure.&lt;/strong&gt; Webhooks, credentials, and execution logs never leave the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom nodes.&lt;/strong&gt; You can &lt;code&gt;npm install&lt;/code&gt; community nodes the cloud version doesn't ship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No execution limits.&lt;/strong&gt; Long-running flows that hit cloud step caps just run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off is operational ownership — you patch the OS, renew the certificate, watch the disk. Most of that is one-time setup. The rest of this guide is the one-time setup, done properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A VPS with Ubuntu 22.04 or 24.04 and SSH access — any of Hetzner, DigitalOcean, Vultr, Linode work fine&lt;/li&gt;
&lt;li&gt;A domain you control with an A record pointing at the VPS IP&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; account (free trial — sign-up link at the end)&lt;/li&gt;
&lt;li&gt;A GitHub or GitLab repository&lt;/li&gt;
&lt;li&gt;Docker installed locally for testing&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Provision the VPS
&lt;/h2&gt;

&lt;p&gt;SSH in as root, install Docker, and set up a deploy user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh root@your-vps-ip
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | sh

adduser deploy
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker deploy
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /home/deploy/n8n
&lt;span class="nb"&gt;chown &lt;/span&gt;deploy:deploy /home/deploy/n8n

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From your local machine, copy your SSH key to the deploy user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id deploy@your-vps-ip

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm passwordless login works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh deploy@your-vps-ip &lt;span class="s2"&gt;"docker --version"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the installed Docker version. That's the account &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; will use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The n8n Docker Compose stack
&lt;/h2&gt;

&lt;p&gt;In your local repo, create &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_USER}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_DB}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${POSTGRES_USER}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${POSTGRES_DB}"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;

  &lt;span class="na"&gt;n8n&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;n8nio/n8n:1.74.0&lt;/span&gt; &lt;span class="c1"&gt;# pin to a specific tag — don't use :latest in prod&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:5678:5678"&lt;/span&gt; &lt;span class="c1"&gt;# localhost only — Nginx will proxy&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_TYPE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresdb&lt;/span&gt;
      &lt;span class="na"&gt;DB_POSTGRESDB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;DB_POSTGRESDB_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
      &lt;span class="na"&gt;DB_POSTGRESDB_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_DB}&lt;/span&gt;
      &lt;span class="na"&gt;DB_POSTGRESDB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_USER}&lt;/span&gt;
      &lt;span class="na"&gt;DB_POSTGRESDB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;N8N_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${N8N_HOST}&lt;/span&gt;
      &lt;span class="na"&gt;N8N_PROTOCOL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
      &lt;span class="na"&gt;N8N_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5678&lt;/span&gt;
      &lt;span class="na"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://${N8N_HOST}/&lt;/span&gt;
      &lt;span class="na"&gt;GENERIC_TIMEZONE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${TIMEZONE:-Europe/London}&lt;/span&gt;
      &lt;span class="na"&gt;N8N_ENCRYPTION_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${N8N_ENCRYPTION_KEY}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;n8n_data:/home/node/.n8n&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./workflows:/workflows:ro&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;n8n_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things matter here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Postgres, not SQLite.&lt;/strong&gt; SQLite is fine for kicking the tires. For anything you care about, Postgres handles concurrent executions, backups, and growth without complaint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;N8N_ENCRYPTION_KEY&lt;/code&gt; is sacred.&lt;/strong&gt; It encrypts the credentials n8n stores in the database. Change it and every credential breaks — you'll have to re-enter every API key, OAuth token, and SSH credential. Generate it once with &lt;code&gt;openssl rand -hex 32&lt;/code&gt;, store it in &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; as a config file, and never lose it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WEBHOOK_URL&lt;/code&gt; must be your public HTTPS URL.&lt;/strong&gt; n8n bakes this into the URLs it gives webhook senders. If it's wrong, Stripe / GitHub / Slack will hit a dead address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port 5678 is bound to &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/strong&gt; Don't expose n8n directly to the internet — put it behind Nginx with TLS. Anyone hitting port 5678 from outside gets nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Create &lt;code&gt;.env.example&lt;/code&gt; to commit to the repo (without secrets):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;n8n&lt;/span&gt;
&lt;span class="py"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;n8n&lt;/span&gt;
&lt;span class="py"&gt;N8N_HOST&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;workflows.yourdomain.com&lt;/span&gt;
&lt;span class="py"&gt;N8N_ENCRYPTION_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;TIMEZONE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Europe/London&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real &lt;code&gt;.env&lt;/code&gt; lives in DeployHQ's server config — never in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Put Nginx in front with Let's Encrypt
&lt;/h2&gt;

&lt;p&gt;On the VPS, install Nginx and Certbot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nginx certbot python3-certbot-nginx

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a server block at &lt;code&gt;/etc/nginx/sites-available/n8n&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;workflows.yourdomain.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;50M&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:5678&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;'upgrade'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_cache_bypass&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;# long-running executions&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_send_timeout&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable it and run Certbot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; workflows.yourdomain.com

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;proxy_read_timeout 3600&lt;/code&gt; is important. n8n executions can run for minutes when a workflow waits on an external API — without that, Nginx kills the connection and the run appears to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: First run
&lt;/h2&gt;

&lt;p&gt;Back on your local machine, commit the repo and push it to GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git init &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git add &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Initial n8n stack"&lt;/span&gt;
git remote add origin git@github.com:you/n8n-stack.git
git push &lt;span class="nt"&gt;-u&lt;/span&gt; origin main

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the very first deploy, SSH into the VPS, copy the &lt;code&gt;.env&lt;/code&gt; over (DeployHQ will manage it from here on), and bring the stack up manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh deploy@your-vps-ip
&lt;span class="nb"&gt;cd&lt;/span&gt; /home/deploy/n8n
&lt;span class="c"&gt;# (DeployHQ will populate docker-compose.yml on first deploy — for now scp it manually)&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;code&gt;https://workflows.yourdomain.com&lt;/code&gt;, create the admin account, and you're live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Wire up Git-based deploys with DeployHQ
&lt;/h2&gt;

&lt;p&gt;This is the part most n8n tutorials skip. In DeployHQ:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a new project&lt;/strong&gt; and point it at your repo. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; supports &lt;a href="https://www.deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;deploying directly from a GitHub repo to your server&lt;/a&gt; without you wiring webhooks by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the VPS as a server.&lt;/strong&gt; Username &lt;code&gt;deploy&lt;/code&gt;, port 22, deployment path &lt;code&gt;/home/deploy/n8n&lt;/code&gt;. Use the SSH key you authorised in Step 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the &lt;code&gt;.env&lt;/code&gt; as a config file.&lt;/strong&gt; In the server's config-files section, create &lt;code&gt;/home/deploy/n8n/.env&lt;/code&gt; and paste the real values (Postgres password, &lt;code&gt;N8N_ENCRYPTION_KEY&lt;/code&gt;, &lt;code&gt;N8N_HOST&lt;/code&gt;). &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; writes this file before every deploy — your secrets stay out of Git.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure the deploy SSH command:&lt;/strong&gt; &lt;code&gt;bash
cd /home/deploy/n8n &amp;amp;&amp;amp; \
docker compose pull &amp;amp;&amp;amp; \
docker compose up -d &amp;amp;&amp;amp; \
docker compose exec -T n8n n8n import:workflow --input=/workflows/ --separate
&lt;/code&gt;This pulls any new n8n image version, recreates containers with the new Compose config, and re-imports every workflow JSON from the mounted &lt;code&gt;/workflows&lt;/code&gt; directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable auto-deploy on push to &lt;code&gt;main&lt;/code&gt;.&lt;/strong&gt; Every merge now triggers a deploy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Push a no-op change to trigger the first run. Watch the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; log — you'll see the repo transferred, the env file written, the SSH commands executed. The site shouldn't blink: &lt;code&gt;docker compose up -d&lt;/code&gt; only recreates containers if something actually changed.&lt;/p&gt;

&lt;p&gt;If a deploy ever breaks something, &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;DeployHQ's one-click rollback&lt;/a&gt; restores the previous repo state and re-runs the deploy command — a single button, no manual Compose juggling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Workflows as code
&lt;/h2&gt;

&lt;p&gt;This is the payoff for everything above. Instead of &lt;q&gt;the workflow is whatever I last clicked in the UI,&lt;/q&gt; your repo becomes the source of truth.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Develop locally or on a staging instance.&lt;/strong&gt; A spare n8n container on your laptop works fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export the workflow.&lt;/strong&gt; In the n8n UI: open the workflow → menu → &lt;q&gt;Download&lt;/q&gt; → save the JSON to &lt;code&gt;workflows/your-workflow-name.json&lt;/code&gt; in your repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit and push.&lt;/strong&gt; &lt;code&gt;bash
git add workflows/your-workflow-name.json
git commit -m "Add: Slack-to-Sheets sync workflow"
git push
&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeployHQ deploys.&lt;/strong&gt; The deploy command runs &lt;code&gt;n8n import:workflow --input=/workflows/ --separate&lt;/code&gt;, which upserts the workflow into the database. Existing workflows with the same ID are updated; new ones are created.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A few practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One JSON file per workflow.&lt;/strong&gt; The &lt;code&gt;--separate&lt;/code&gt; flag tells n8n's importer to treat each file independently. Easier diffs in PRs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials are not in the JSON.&lt;/strong&gt; n8n stores them encrypted in the database, referenced by ID. Set up credentials once in the UI; the workflow JSON just references them. This is why &lt;code&gt;N8N_ENCRYPTION_KEY&lt;/code&gt; matters — drop it and the references become unusable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The UI is now read-only in your head.&lt;/strong&gt; Anyone making changes in production goes back to staging, exports, commits. Drift kills you otherwise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same Compose pattern works for sidecar services — drop a Python agent or a Postgres backup container into the same &lt;code&gt;docker-compose.yml&lt;/code&gt;, and &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deploys all of it together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Upgrade n8n safely
&lt;/h2&gt;

&lt;p&gt;n8n releases often. The temptation is to use &lt;code&gt;n8nio/n8n:latest&lt;/code&gt; and let it ride. Don't.&lt;/p&gt;

&lt;p&gt;With a pinned tag, upgrading becomes a one-line PR:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- image: n8nio/n8n:1.74.0
&lt;/span&gt;&lt;span class="gi"&gt;+ image: n8nio/n8n:1.78.0
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deploys, the new image is pulled, the container recreated. If anything misbehaves, hit rollback and the previous tag is back in seconds. You get a git history of every n8n version that has ever run in production — handy when a workflow stops working and you need to find what changed.&lt;/p&gt;

&lt;p&gt;Two upgrade hygiene tips:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the changelog&lt;/strong&gt; before bumping a major. n8n occasionally deprecates nodes or changes execution semantics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot Postgres before a major.&lt;/strong&gt; A &lt;code&gt;docker compose exec postgres pg_dump ...&lt;/code&gt; to a file in a backup directory takes seconds and saves hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For deploys in general, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; runs &lt;a href="https://www.deployhq.com/features/zero-downtime-deployments" rel="noopener noreferrer"&gt;zero downtime deployments&lt;/a&gt; by default — when you eventually move to a multi-server setup, the rollout pattern stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Backups
&lt;/h2&gt;

&lt;p&gt;Two things to back up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Postgres database.&lt;/strong&gt; Everything important — workflows, credentials, execution history — lives here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres &lt;span class="se"&gt;\&lt;/span&gt;
  pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; n8n &lt;span class="nt"&gt;-d&lt;/span&gt; n8n &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--clean&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop a small script in your repo at &lt;code&gt;scripts/backup.sh&lt;/code&gt; that runs this and uploads the file to S3 (or B2, or any object store). Cron it nightly on the VPS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;n8n_data&lt;/code&gt; volume.&lt;/strong&gt; Holds binary attachments and custom nodes. Tar it once a week to the same store:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; n8n_n8n_data:/data &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;$PWD&lt;/span&gt;:/backup alpine &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/n8n-data-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflows themselves are already in Git, so you don't need to back those up — that's the whole point of the workflows-as-code pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;The pillar above gets you a single-node, production-ready n8n instance with version control and rollback. From here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Drop a custom AI service next to n8n.&lt;/strong&gt; The same &lt;code&gt;docker-compose.yml&lt;/code&gt; handles a sidecar Python or Node service — expose it at &lt;code&gt;http://agent:8000&lt;/code&gt; and have any n8n workflow hit it as a webhook. Same VPS, same deploy pipeline, no extra infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare with other self-hosted agent platforms.&lt;/strong&gt; If n8n's node-based model isn't the right fit, look at alternatives covered on the blog: &lt;a href="https://dev.to/deployhq/self-hosting-paperclip-on-a-vps-with-docker-and-continuous-deployment-4hh5-temp-slug-5506684"&gt;Paperclip as a self-hosted agent orchestrator&lt;/a&gt;, &lt;a href="https://dev.to/theqadiariesforyou/how-to-deploy-and-configure-openclaw-on-a-vps-4h8e-temp-slug-2543902"&gt;OpenClaw as a self-hosted AI assistant with a Skills plugin system&lt;/a&gt;, and &lt;a href="https://dev.to/deployhq/deploy-hermes-agent-on-a-vps-deployhq-workflow-how-it-differs-from-openclaw-34d9-temp-slug-5607414"&gt;Hermes Agent for self-improving agents on a VPS&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run agents inside CI/CD.&lt;/strong&gt; For a different shape of the same problem — agents that act on your repository — see &lt;a href="https://dev.to/deployhq/ai-agents-in-cicd-pipelines-from-github-issue-to-production-deploy-5b4b-temp-slug-7931927"&gt;how AI agents fit into CI/CD pipelines from GitHub issue to production deploy&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drive deploys from a terminal agent.&lt;/strong&gt; If you want Claude Code, Cursor, or Codex to trigger n8n redeploys for you, &lt;a href="https://dev.to/deployhq/deployhq-cli-deploy-from-your-terminal-or-let-your-ai-agent-do-it-7le-temp-slug-4729135"&gt;the&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; CLI exposes a deployment trigger your AI agent can call.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Ready to put n8n on infrastructure you control, with a Git-driven deploy pipeline behind it? &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Start a free&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; trial and wire your first push-to-deploy n8n stack in under twenty minutes.&lt;/p&gt;

&lt;p&gt;Questions? Email us at &lt;strong&gt;&lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt;&lt;/strong&gt; or find us on X at &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;@deployhq&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>docker</category>
      <category>n8n</category>
      <category>vps</category>
    </item>
    <item>
      <title>PR Radar vs GitHub Notifications vs Email: How Developers Actually Track Pull Requests</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Fri, 24 Apr 2026 12:32:48 +0000</pubDate>
      <link>https://dev.to/deployhq/pr-radar-vs-github-notifications-vs-email-how-developers-actually-track-pull-requests-4dp5</link>
      <guid>https://dev.to/deployhq/pr-radar-vs-github-notifications-vs-email-how-developers-actually-track-pull-requests-4dp5</guid>
      <description>&lt;p&gt;If you've managed more than a handful of pull requests across more than one Git platform, you've probably built your own little tracking system out of whatever was free and already installed. A few browser tabs. Email filters. Maybe a Slack integration that someone turned on two years ago and nobody can remember the auth for. It works, until it doesn't — until the PR that needed the &lt;q&gt;please merge today&lt;/q&gt; comment gets buried under twelve identical CI notifications, or until the GitLab side of your migration stays invisible for three hours because you only had the GitHub tab open.&lt;/p&gt;

&lt;p&gt;This is a comparison of the ways developers actually track PRs today — including &lt;a href="https://www.deployhq.com/features/pr-radar" rel="noopener noreferrer"&gt;PR Radar&lt;/a&gt;, the open-source Chrome extension we built because none of the existing options did what we wanted. We'll be honest about where each one wins and where it falls short, and we'll end with the one question that decides which tool you actually need. It's the same format we used for our &lt;a href="https://www.deployhq.com/blog/choosing-the-right-package-manager-npm-vs-yarn-vs-pnpm-vs-bun" rel="noopener noreferrer"&gt;package manager benchmark&lt;/a&gt; and our &lt;a href="https://dev.to/deployhq/mailtrap-vs-sendgrid-vs-mailgun-best-email-api-for-nodejs-in-2026-1m0-temp-slug-4999684"&gt;transactional email API comparison&lt;/a&gt; — practical, developer-to-developer, and honest about the gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five options on the table
&lt;/h2&gt;

&lt;p&gt;Before the comparison, here's the shortlist. We've stayed focused on tools that &lt;strong&gt;monitor PR status&lt;/strong&gt; rather than tools that try to change your PR workflow (so Graphite, Aviator, and Reviewpad are out — they solve a different problem).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Email notifications&lt;/strong&gt; — the default for GitHub, GitLab, and Bitbucket&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub's built-in notification inbox&lt;/strong&gt; — the bell icon, the &lt;code&gt;/notifications&lt;/code&gt; page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack integrations&lt;/strong&gt; — GitHub for Slack, GitLab for Slack, Bitbucket's bot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub-specific extensions&lt;/strong&gt; — Refined GitHub, Notifier for GitHub&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PR Radar&lt;/strong&gt; — multi-platform dashboard in the browser toolbar&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Option 1: Email — the default everyone ignores
&lt;/h2&gt;

&lt;p&gt;Every Git host turns on email notifications by default. Every developer turns most of them off within a month.&lt;/p&gt;

&lt;p&gt;The problem with email isn't that it's a bad channel. It's that PR events are high-frequency and low-information per message. A typical active PR generates email for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The initial open&lt;/li&gt;
&lt;li&gt;Every review comment&lt;/li&gt;
&lt;li&gt;Every reply in a thread&lt;/li&gt;
&lt;li&gt;Every push that re-triggers CI&lt;/li&gt;
&lt;li&gt;Every CI status change&lt;/li&gt;
&lt;li&gt;Merge or close&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Multiply by 8–12 active PRs in a normal week and you've built a firehose. The signal-to-noise ratio collapses because every email looks roughly the same in the preview pane: a PR title, a repo name, and one line of context. You cannot tell at a glance which of your fifteen unread messages is &lt;q&gt;CI failed on the PR that's blocking release&lt;/q&gt; and which is &lt;q&gt;approving nit on a docs typo.&lt;/q&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where email wins:&lt;/strong&gt; Audit trail. If you need a durable record of what happened and when, email is it. Good for compliance, useless for real-time awareness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it fails:&lt;/strong&gt; Real-time awareness, cross-platform visibility, CI status at a glance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Option 2: GitHub's notification inbox
&lt;/h2&gt;

&lt;p&gt;GitHub's inbox (the bell icon and &lt;code&gt;/notifications&lt;/code&gt;) is better than email for one reason: it's scoped and threaded. A single PR becomes one inbox entry that updates in place instead of twenty separate emails.&lt;/p&gt;

&lt;p&gt;It has genuine improvements over the last couple of years — filters, custom views, &lt;q&gt;Participating&lt;/q&gt; vs &lt;q&gt;All,&lt;/q&gt; per-repo mute. If you live entirely inside GitHub, the inbox is solid.&lt;/p&gt;

&lt;p&gt;But it has three hard ceilings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's GitHub-only.&lt;/strong&gt; If your team uses GitLab for infra and GitHub for apps, the inbox solves half your problem. The other half you track… somehow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't show CI status inline.&lt;/strong&gt; The inbox will tell you &lt;q&gt;CI ran on this PR.&lt;/q&gt; It will not tell you whether it passed or failed without a click into the PR page. For a tool whose primary job is telling you what needs your attention, burying the pass/fail state behind a click is a miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It needs a tab open.&lt;/strong&gt; The inbox icon lives inside &lt;code&gt;github.com&lt;/code&gt;. If you closed the tab during a focus block, you find out about a failed deploy the next time you cmd-T your way back. Browser desktop notifications exist but are &lt;a href="https://docs.github.com/en/account-and-profile/managing-subscriptions-and-notifications-on-github/setting-up-notifications/configuring-notifications" rel="noopener noreferrer"&gt;disabled by default&lt;/a&gt; and, even when on, only fire when a GitHub tab is already open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it wins:&lt;/strong&gt; Native, reliable, no extra auth, granular filters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it fails:&lt;/strong&gt; GitHub-only, no CI status in the list view, tab-bound.&lt;/p&gt;




&lt;h2&gt;
  
  
  Option 3: Slack integrations
&lt;/h2&gt;

&lt;p&gt;Slack is where most engineering teams already are, so routing PR events into Slack seems obvious. And for a few specific use cases — a dedicated &lt;code&gt;#deploys&lt;/code&gt; channel, a release-critical PR that the whole team is watching — it's the right tool.&lt;/p&gt;

&lt;p&gt;For everyday PR tracking, Slack is a noise amplifier. A rebase-heavy afternoon on a single PR can push twenty notifications into a channel. The team stops reading them. Important signals (&lt;q&gt;production deploy failed&lt;/q&gt;) end up in the same channel as unimportant ones (&lt;q&gt;renovate bumped a patch version&lt;/q&gt;), with the same visual weight, both probably muted by Thursday.&lt;/p&gt;

&lt;p&gt;There's also a coverage asymmetry. GitHub for Slack is mature. GitLab for Slack is functional. Bitbucket's Slack integration exists but feels like an afterthought. If you've got repos on all three, Slack gives you an uneven view of each one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it wins:&lt;/strong&gt; Team visibility on critical events, shared context during an incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it fails:&lt;/strong&gt; Personal PR tracking, signal-to-noise, multi-platform parity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Option 4: Refined GitHub and Notifier for GitHub
&lt;/h2&gt;

&lt;p&gt;If you've looked for browser extensions for this before, you've hit two classic options.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/refined-github/refined-github" rel="noopener noreferrer"&gt;Refined GitHub&lt;/a&gt; is excellent — dozens of polish-level improvements to &lt;code&gt;github.com&lt;/code&gt; pages. But it enhances GitHub; it doesn't pull PR status &lt;em&gt;out&lt;/em&gt; of GitHub into your toolbar. You still need to open the tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notifier for GitHub&lt;/strong&gt; does put a badge in your toolbar, but the badge counts unread notifications — not CI state, not review state. A red badge tells you &lt;q&gt;there's something&lt;/q&gt; and nothing else. You click through to find out what.&lt;/p&gt;

&lt;p&gt;Both are GitHub-only, both assume GitHub is your whole world, and neither addresses the core visibility problem if your work spans platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where they win:&lt;/strong&gt; Free, lightweight, GitHub UX polish (Refined GitHub in particular is a staple).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where they fail:&lt;/strong&gt; Single-platform, no CI awareness in Notifier, no toolbar summary in Refined.&lt;/p&gt;




&lt;h2&gt;
  
  
  Option 5: PR Radar
&lt;/h2&gt;

&lt;p&gt;PR Radar is the extension we built after rotating through all four options above and still ending up with a browser window full of tabs. It's a Chrome extension (Firefox and Edge in progress), MIT-licensed, open source, and free — no paid tier, no accounts, no backend at all. Links to the store listing and repo are at the end of the post.&lt;/p&gt;

&lt;p&gt;The short version of what it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One toolbar popup&lt;/strong&gt; with every open PR across GitHub, GitLab, and Bitbucket&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Badge on the toolbar icon&lt;/strong&gt; with a live pass / fail / running count for CI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unresolved comment count&lt;/strong&gt; per PR, pulled via GraphQL so the number is actually correct&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment status inline&lt;/strong&gt; with clickable environment URLs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Desktop notifications and sound alerts&lt;/strong&gt; when CI finishes (no tab required — it polls in a service worker)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-click merge&lt;/strong&gt; from the dashboard, across all three platforms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale PR dimming&lt;/strong&gt; with a configurable threshold so your attention goes to the active ones&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyboard shortcuts&lt;/strong&gt; — &lt;code&gt;j&lt;/code&gt;/&lt;code&gt;k&lt;/code&gt; to move between PRs, &lt;code&gt;o&lt;/code&gt; to open, &lt;code&gt;/&lt;/code&gt; to search, &lt;code&gt;?&lt;/code&gt; for the full list&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design goal was minimum cognitive load: glance at the toolbar, see whether anything needs you, go back to what you were doing. If the badge is green, there is nothing to do. If it's red, you know exactly where to look. The same mindset drove our list of &lt;a href="https://dev.to/deployhq/6-developer-clis-that-ai-coding-agents-actually-use-well-5973-temp-slug-2135258"&gt;developer CLIs that AI agents use well&lt;/a&gt; — a good dev tool should compress ten decisions into one glance.&lt;/p&gt;

&lt;p&gt;Privacy-wise, PR Radar doesn't send your tokens anywhere. Your personal access tokens sit in the browser's local storage, and the extension makes API calls directly from your browser to each Git platform. There is no PR Radar backend. We don't run analytics. There's no &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; account required — the extension works whether or not you're a &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; customer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side-by-side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Email&lt;/th&gt;
&lt;th&gt;GitHub Inbox&lt;/th&gt;
&lt;th&gt;Slack&lt;/th&gt;
&lt;th&gt;Refined / Notifier&lt;/th&gt;
&lt;th&gt;PR Radar&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitLab&lt;/td&gt;
&lt;td&gt;✅ (via email)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️ Uneven&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitbucket&lt;/td&gt;
&lt;td&gt;✅ (via email)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️ Uneven&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI status at a glance&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️ In channel&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Badge + inline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unresolved comment count&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy status in PR list&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works without a tab open&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Service worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sound / desktop alerts&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️ If tab open&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merge from the tool&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ All 3 platforms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Free tier + Slack cost&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Free + open source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privacy&lt;/td&gt;
&lt;td&gt;Platform stores email&lt;/td&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;Slack + platform&lt;/td&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;100% local, no backend&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is consistent: each mainstream option handles one or two of these well and treats the rest as out of scope. For developers who only use GitHub and already live in Slack, that's fine. For anyone working across platforms, the gaps add up fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  The deployment connection
&lt;/h2&gt;

&lt;p&gt;If you're reading this on the &lt;a href="https://www.deployhq.com/blog" rel="noopener noreferrer"&gt;DeployHQ blog&lt;/a&gt;, there's a fair chance you care specifically about the last mile: the moment between &lt;q&gt;CI passed&lt;/q&gt; and &lt;q&gt;change is live.&lt;/q&gt; That moment is where PR tracking stops being a productivity nice-to-have and starts being a deployment-quality thing.&lt;/p&gt;

&lt;p&gt;Two concrete examples:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catching a broken deploy before the channel sees it.&lt;/strong&gt; When CI includes a deploy step (either an &lt;a href="https://www.deployhq.com/features/automatic-deployments" rel="noopener noreferrer"&gt;automatic deployment from Git&lt;/a&gt; or a &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;preview environment triggered by PR&lt;/a&gt;), the failure signal travels through the same notification pipes as every other CI event. In email or Slack, a failed deploy looks like every other failed check. In PR Radar, the toolbar flips red the moment the job changes state, with the deploy URL one click away. That's a four-second feedback loop instead of a forty-minute one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merging without a tab full of PRs.&lt;/strong&gt; DeployHQ's &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;one-click rollback&lt;/a&gt; and &lt;a href="https://www.deployhq.com/features/zero-downtime-deployments" rel="noopener noreferrer"&gt;zero-downtime deployments&lt;/a&gt; are designed to make shipping cheap. The bottleneck in that flow is usually human: somebody has to decide the PR is ready and hit merge. If that decision point lives in a popup rather than on a PR page buried in a tab group, the whole cycle tightens.&lt;/p&gt;

&lt;p&gt;Neither of these is the reason to install a browser extension on its own. But if you already care enough about deployment velocity to be reading this, the PR tracking side of the workflow probably deserves the same attention you've given &lt;a href="https://dev.to/deployhq/agentic-workflows-explained-how-ai-agents-are-changing-cicd-pipelines-nm0-temp-slug-2291085"&gt;your CI/CD pipeline&lt;/a&gt; and &lt;a href="https://www.deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;your deploy automation&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who should pick which
&lt;/h2&gt;

&lt;p&gt;Here's the actual decision tree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You only use GitHub and you're fine with the inbox.&lt;/strong&gt; Stay with it, maybe bolt on Refined GitHub for UI polish. No extension needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You only use GitHub but the inbox feels lossy.&lt;/strong&gt; Try &lt;a href="https://chromewebstore.google.com/detail/notifier-for-github/lmjdlojahmbbcodnpecnjnmlddbkjhnn" rel="noopener noreferrer"&gt;Notifier for GitHub&lt;/a&gt; for the toolbar badge. If you want CI status specifically and not just unread counts, PR Radar fits even in single-platform setups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You use GitHub + GitLab or GitHub + Bitbucket.&lt;/strong&gt; This is the case email, the inbox, and GitHub-specific extensions all fail at. A dedicated multi-platform tool is the only real answer. PR Radar is the one we built; there isn't much direct competition in the free / privacy-first slice of that category.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're a team lead who wants team-wide visibility into deploys.&lt;/strong&gt; Slack is still right for that — route release-critical events into a dedicated channel. Just don't try to use the same Slack integration for personal PR tracking; it's the wrong tool for the wrong granularity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You care about privacy or you work on client repos with sensitive PR titles.&lt;/strong&gt; PR Radar keeps everything local — tokens in browser storage, API calls direct to platforms, no backend, no analytics. Hosted PR tools generally require you to authorize an OAuth app that sees your PR content. That's a real trade-off worth knowing about, in the same category as picking &lt;a href="https://dev.to/deployhq/6-must-have-mcp-servers-for-web-developers-in-2025-35no"&gt;which MCP servers you grant access to your codebase&lt;/a&gt; or &lt;a href="https://dev.to/deployhq/how-to-use-git-with-claude-code-understanding-the-co-authored-by-attribution-3boi"&gt;how you wire Claude Code into Git&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;PR Radar is free, open source (MIT), and takes about a minute to set up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Install from the &lt;a href="https://chromewebstore.google.com/detail/pr-radar-pr-dashboard-ci/hkombgibegjffiadmekpiabdakkoidmh" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt;&lt;/strong&gt; (Firefox and Edge builds are in progress on the &lt;a href="https://github.com/deployhq/pr-radar/releases" rel="noopener noreferrer"&gt;GitHub releases page&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Star the repo on &lt;a href="https://github.com/deployhq/pr-radar" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/strong&gt; if it saves you a tab — it's how we know to keep investing in it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File an issue&lt;/strong&gt; if a platform quirk breaks your setup; we read all of them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're already on &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, PR Radar closes the visibility gap between &lt;q&gt;PR looks good&lt;/q&gt; and &lt;q&gt;change is live&lt;/q&gt; without asking you to change anything about your deployment workflow. If you're not, it's still the fastest way we've found to stop tab-hopping between Git hosts.&lt;/p&gt;




&lt;p&gt;Questions, platform-specific gotchas, or feature requests? Email us at &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or ping us on &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;Twitter/X&lt;/a&gt;. If you'd rather build your own PR dashboard on top of the GraphQL APIs, the source is MIT-licensed and PRs are welcome.&lt;/p&gt;

</description>
      <category>prs</category>
      <category>github</category>
      <category>gitlab</category>
      <category>bitbutcket</category>
    </item>
    <item>
      <title>Mailtrap vs SendGrid vs Mailgun: Best Email API for Node.js in 2026</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Wed, 15 Apr 2026 05:07:04 +0000</pubDate>
      <link>https://dev.to/deployhq/mailtrap-vs-sendgrid-vs-mailgun-best-email-api-for-nodejs-in-2026-1ng7</link>
      <guid>https://dev.to/deployhq/mailtrap-vs-sendgrid-vs-mailgun-best-email-api-for-nodejs-in-2026-1ng7</guid>
      <description>&lt;p&gt;Mailtrap, SendGrid, and Mailgun are the three most common email APIs that Node.js developers use for transactional or promotional email sending. Each takes a different approach to SDK design, deliverability architecture, and pricing, so the right choice depends on what your team actually needs.&lt;/p&gt;

&lt;p&gt;This comparison covers installation, deliverability infrastructure, API workflow capabilities, MCP support, and pricing. All three were tested by integrating their SDKs into a Node.js project and sending emails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison Overview
&lt;/h2&gt;

&lt;p&gt;All three tools offer straightforward npm installation, but there are differences worth noting in setup complexity, package size, and ecosystem maturity.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Email API&lt;/th&gt;
&lt;th&gt;Setup Time&lt;/th&gt;
&lt;th&gt;NPM Downloads / Week&lt;/th&gt;
&lt;th&gt;SDK Size&lt;/th&gt;
&lt;th&gt;TypeScript Support&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mailtrap&lt;/td&gt;
&lt;td&gt;5 mins&lt;/td&gt;
&lt;td&gt;28K+&lt;/td&gt;
&lt;td&gt;90.4 kB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mailgun&lt;/td&gt;
&lt;td&gt;10-15 mins&lt;/td&gt;
&lt;td&gt;360K+&lt;/td&gt;
&lt;td&gt;1.3 MB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SendGrid&lt;/td&gt;
&lt;td&gt;10-15 mins&lt;/td&gt;
&lt;td&gt;1.65M+&lt;/td&gt;
&lt;td&gt;17.4 kB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Installation and Setup Comparison
&lt;/h2&gt;

&lt;p&gt;When integrating an email service into a &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;deployment pipeline&lt;/a&gt;, you want an SDK that configures quickly and does not introduce brittle dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mailtrap
&lt;/h3&gt;

&lt;p&gt;Setup time: 5 minutes. Complexity: Easy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;npm&lt;/span&gt; &lt;span class="nx"&gt;install&lt;/span&gt; &lt;span class="nx"&gt;mailtrap&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MailtrapClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mailtrap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MailtrapClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MAILTRAP_API_TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recipient@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello from Mailtrap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This is a test email.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Mailtrap package follows a clean API design with intuitive REST principles. It does not require complex DNS setup for initial sandbox testing, so developers can configure their environment variables and fire off a test email right away.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mailgun
&lt;/h3&gt;

&lt;p&gt;Setup time: 10-15 minutes. Complexity: Medium to complex.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;npm&lt;/span&gt; &lt;span class="nx"&gt;install&lt;/span&gt; &lt;span class="nx"&gt;mailgun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Mailgun&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mailgun.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mailgun&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Mailgun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mailgun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MAILGUN_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;mg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your-domain.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello@your-domain.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recipient@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello from Mailgun&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This is a test email.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mailgun requires DNS configuration for domain verification before you can send production emails. The SDK also depends on &lt;code&gt;form-data&lt;/code&gt; as a peer dependency, which adds a step. Understanding the email validation features adds initial overhead too.&lt;/p&gt;

&lt;h3&gt;
  
  
  SendGrid
&lt;/h3&gt;

&lt;p&gt;Setup time: 10-15 minutes. Complexity: Medium.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;npm&lt;/span&gt; &lt;span class="nx"&gt;install&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;sendgrid&lt;/span&gt;&lt;span class="sr"&gt;/mai&lt;/span&gt;&lt;span class="err"&gt;l
&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sgMail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@sendgrid/mail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;sgMail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setApiKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SENDGRID_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;sgMail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recipient@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello from SendGrid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This is a test email.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@sendgrid/mail&lt;/code&gt; package requires additional configuration for sender authentication and strict API key management. There are multiple ways to structure email payloads, which can be confusing during initial setup if you are working from the docs (they carry a lot of legacy content for different API versions).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Getting started verdict:&lt;/strong&gt; Mailtrap has the lowest friction for a first send. SendGrid and Mailgun both take 10-15 minutes depending on DNS propagation and how familiar you are with their respective dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deliverability and Infrastructure
&lt;/h2&gt;

&lt;p&gt;How each service handles deliverability architecture matters more than raw setup speed once you are sending production emails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mailtrap
&lt;/h3&gt;

&lt;p&gt;Mailtrap enforces strict separation between transactional and bulk message streams. This is an architectural decision, not just a UI toggle. Transactional emails (password resets, order confirmations, 2FA codes) run on a dedicated IP pool that is never affected by the reputation of your marketing sends. If a promotional campaign triggers spam complaints, your critical application notifications keep arriving. Mailtrap also offers a 99.99% uptime guarantee with compensation, and detailed analytics that track opens, clicks, and bounces for up to 30 days.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mailgun
&lt;/h3&gt;

&lt;p&gt;Mailgun focuses heavily on pre-send list quality. It validates email addresses by checking DNS MX records, disposable address databases, and mailbox existence against a 450+ billion email dataset. If you have older contact lists with unknown deliverability, this validation layer can meaningfully reduce your bounce rates before you even send. Mailgun also maintains automated suppression lists that remove hard bounces and spam complainers from future sends, which protects your sending domain over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  SendGrid
&lt;/h3&gt;

&lt;p&gt;SendGrid operates at a massive scale. Its infrastructure handles enterprise-level volume for both marketing and transactional email, backed by Twilio's network. It also offers native Handlebars template logic stored in the UI and programmatic automation for drip campaigns. For large organizations that need a single platform to handle everything from password resets to weekly newsletters, SendGrid's breadth is hard to match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliverability verdict:&lt;/strong&gt; Mailgun's pre-send validation against 450+ billion records and automated suppression lists give it the most complete deliverability toolkit. Mailtrap's stream separation is a solid architectural advantage for apps mixing transactional and promotional email. SendGrid's scale and Twilio-backed infrastructure make it the safest bet for high-volume senders.&lt;/p&gt;

&lt;h2&gt;
  
  
  API Workflow Capabilities
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Creating Email Templates
&lt;/h3&gt;

&lt;p&gt;SendGrid lets marketing teams store email templates directly in its UI using Handlebars syntax. Content updates don't require code deployments. Mailtrap and Mailgun offer full API control over templates, but developers need to manage the payload construction themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event Webhooks
&lt;/h3&gt;

&lt;p&gt;Webhooks let your Node.js server passively listen for delivery events.&lt;/p&gt;

&lt;p&gt;Mailtrap delivers full payload data for delivered, opened, and bounced events, with 40 internal retries every five minutes to make sure events are not dropped. Mailgun includes message IDs alongside click and bounce events, which is useful for debugging delivery issues. SendGrid tracks standard events but restricts free-tier users to a single endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Framework Integration
&lt;/h3&gt;

&lt;p&gt;All three providers offer SDKs that work with Express.js, Next.js, NestJS, Fastify, and Koa. They all support standard async/await patterns and compile-time error checking through TypeScript. Framework compatibility is not a differentiator here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API workflow verdict:&lt;/strong&gt; SendGrid's Handlebars-based template system lets users update email content without code deployments, and the broader ecosystem means more community examples and integrations. Mailgun's message-ID tracking is the strongest option for debugging delivery issues. Mailtrap's webhook retry mechanism is worth noting for teams that need guaranteed event delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP and Extensibility
&lt;/h2&gt;

&lt;p&gt;All three tools support the Model Context Protocol (MCP) for extending AI capabilities into IDEs and terminal workflows.&lt;/p&gt;

&lt;p&gt;Mailtrap provides an official MCP server focused on deliverability, template management, and multi-recipient sending. Mailgun offers an open-source MCP server for sending emails and retrieving analytics. SendGrid relies on community-created MCP servers for campaign management, which may require manual configuration.&lt;/p&gt;

&lt;p&gt;Common MCP use cases for email include generating and testing HTML email templates via AI, querying bounce rates and delivery logs, and automating recipient list segmentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extensibility verdict:&lt;/strong&gt; Mailtrap and Mailgun both offer maintained MCP servers. SendGrid's community approach gives you more flexibility but less out-of-the-box reliability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing Comparison
&lt;/h2&gt;

&lt;p&gt;Cost is often the deciding factor when scaling a Node.js application's email infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mailtrap
&lt;/h3&gt;

&lt;p&gt;Free tier covers up to 1,000 emails. The Basic plan starts at $15/month for 10,000+ emails. The Business plan runs $85/month for 100,000+ emails. Higher tiers include 24/7 priority support and extended log retention.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mailgun
&lt;/h3&gt;

&lt;p&gt;Free tier allows 100 emails per day. The Foundation plan is $15/month for 10,000 emails. The Scale plan costs $90/month for 100,000 emails. Pricing gets less competitive at higher volumes, but the validation tools can save money by reducing bounces.&lt;/p&gt;

&lt;h3&gt;
  
  
  SendGrid
&lt;/h3&gt;

&lt;p&gt;Free tier offers 100 emails per day on a 60-day trial. Essentials starts at $19.95/month for 50,000 emails. Pro costs $89.95/month for 100,000 emails. The pricing structure is complex with many add-ons, but makes sense for high-volume senders who need marketing and transactional email in one platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing verdict:&lt;/strong&gt; At lower volumes all three are competitive. Mailtrap and Mailgun both start at $15/month. SendGrid costs slightly more but includes a larger email allowance on its Essentials plan. The real cost differences emerge at scale and depend on which features you actually use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unique Strengths
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mailtrap:&lt;/strong&gt; Strict separation of transactional and promotional streams. An official TypeScript SDK with zero unnecessary dependencies. A 99.99% uptime SLA with automatic webhook failure detection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mailgun:&lt;/strong&gt; Industry-leading pre-send email validation against a 450+ billion record database. Batch sending to 1,000 recipients per API call using localized recipient variables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SendGrid:&lt;/strong&gt; The largest Node.js email ecosystem with 1.65 million weekly npm downloads, backed by Twilio's infrastructure. A unified API that handles both transactional coding and visual marketing campaigns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managing Email Credentials in Your Deployment Pipeline
&lt;/h2&gt;

&lt;p&gt;Regardless of which &lt;a href="https://mailtrap.io/email-api/" rel="noopener noreferrer"&gt;email API&lt;/a&gt; you choose, never hardcode API keys in your application code. Inject them through environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EMAIL_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EMAIL_API_KEY is missing from environment.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a common pitfall, especially when switching between staging and production API keys for different email providers. A leaked SendGrid or Mailgun key in a public repo can result in your sending domain getting blacklisted within hours.&lt;/p&gt;

&lt;p&gt;If you are deploying a Node.js app that sends email, your deployment tool should handle these secrets safely. In &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, you can store environment variables as part of your project's &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipeline configuration&lt;/a&gt; and inject them at deploy time. This keeps credentials out of your repository while ensuring each environment (staging, production) uses the correct API keys and SMTP settings.&lt;/p&gt;

&lt;p&gt;You can also use DeployHQ's &lt;a href="https://www.deployhq.com/support/projects/configuration-files" rel="noopener noreferrer"&gt;configuration file feature&lt;/a&gt; to template out &lt;code&gt;.env&lt;/code&gt; files during deployment, swapping placeholders for real values stored in the deployment target settings. For teams running separate transactional and marketing email streams (which Mailtrap enforces by default), this means you can store different API tokens per deployment target and avoid accidentally sending test emails through your production pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recommendations by Use Case
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;For developer and product teams shipping transactional email quickly:&lt;/strong&gt; Mailtrap. The 5-minute setup and strict stream separation let you get to production without surprises. The SDK is small, typed, and does not pull in unnecessary dependencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For teams managing older or unverified contact lists:&lt;/strong&gt; Mailgun. The validation API will reduce your bounce rates before emails ever leave your server. The batch sending feature (up to 1,000 recipients per API call with recipient variables) is also useful for teams sending personalized notifications at moderate scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For enterprise marketing teams that need campaigns and transactional email in one platform:&lt;/strong&gt; SendGrid. The unified ecosystem and Twilio backing handle both use cases, and the 1.65 million weekly npm downloads mean you will find answers to most integration questions on Stack Overflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making Your Choice
&lt;/h2&gt;

&lt;p&gt;There is no wrong choice among these three. Mailtrap, SendGrid, and Mailgun are all actively maintained, well-documented, and capable. All three support async/await, TypeScript, and the major Node.js frameworks. Start with the one that matches your timeline and email types, then evaluate further as your sending volume grows.&lt;/p&gt;

&lt;p&gt;The broader trend across all three providers is convergence around MCP support, TypeScript-first SDKs, and better webhook reliability. Where they still diverge is in deliverability architecture and pricing models. For deployment workflows specifically, the most important factor is how cleanly the SDK integrates with your existing &lt;a href="https://www.deployhq.com/support/projects/environment-variables" rel="noopener noreferrer"&gt;environment variable management&lt;/a&gt; and CI/CD pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Which email API is best for a Node.js beginner?
&lt;/h3&gt;

&lt;p&gt;Mailtrap has the simplest setup at around 5 minutes, with a single npm package and no peer dependencies. SendGrid is also straightforward but has more configuration options that can be confusing initially. If you just need to get a transactional email working quickly, Mailtrap or SendGrid are your best bets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I switch email providers later without rewriting my application?
&lt;/h3&gt;

&lt;p&gt;Yes, if you abstract your email sending behind a service layer. All three providers use similar patterns (API key authentication, JSON payloads, async sending), so swapping one for another typically means changing the SDK import and adjusting the payload format. Keeping your API keys in &lt;a href="https://www.deployhq.com/support/projects/environment-variables" rel="noopener noreferrer"&gt;environment variables&lt;/a&gt; rather than hardcoded makes the switch even simpler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a dedicated IP address for transactional email?
&lt;/h3&gt;

&lt;p&gt;Not necessarily. All three providers offer shared IP pools that work well for most applications. Dedicated IPs become relevant when you send high volumes (50,000+ emails per month) and want full control over your sender reputation. Mailtrap and SendGrid offer dedicated IPs on higher-tier plans; Mailgun includes them on the Scale plan and above.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test emails without sending to real recipients?
&lt;/h3&gt;

&lt;p&gt;Mailtrap was originally built as an email testing tool — its sandbox captures emails in a virtual inbox so you can inspect HTML rendering, spam scores, and headers without sending to real addresses. SendGrid and Mailgun both support sandbox modes as well, though the setup requires additional configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if my email API goes down during a deployment?
&lt;/h3&gt;

&lt;p&gt;Your application should handle email API failures gracefully with retry logic and a message queue. None of these providers guarantee 100% uptime (Mailtrap offers 99.99%, the others are similar). Using &lt;a href="https://www.deployhq.com/features/zero-downtime-deployments" rel="noopener noreferrer"&gt;DeployHQ's zero-downtime deployments&lt;/a&gt; ensures your application stays available during deploys, but your email error handling should account for temporary API outages regardless of provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to use free tiers in production?
&lt;/h3&gt;

&lt;p&gt;Free tiers are fine for low-volume applications, but they come with limitations. Mailtrap's free plan caps at 1,000 emails per month. SendGrid and Mailgun limit you to 100 emails per day. Beyond testing and early-stage projects, you will likely need a paid plan for reliable production sending with higher rate limits and better support.&lt;/p&gt;

&lt;p&gt;Ready to streamline your Node.js deployments? &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Get started with DeployHQ&lt;/a&gt; and automate your deployment pipeline today.&lt;/p&gt;




&lt;p&gt;Have questions? Reach out at &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or find us on &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;X (@deployhq)&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>node</category>
      <category>emailapi</category>
      <category>comparison</category>
    </item>
    <item>
      <title>CLIs or MCP for Coding Agents? A Practical Comparison</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Mon, 13 Apr 2026 08:34:15 +0000</pubDate>
      <link>https://dev.to/deployhq/clis-or-mcp-for-coding-agents-a-practical-comparison-3kim</link>
      <guid>https://dev.to/deployhq/clis-or-mcp-for-coding-agents-a-practical-comparison-3kim</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 5 of our series on AI coding assistants for developers. See also: &lt;a href="https://dev.to/deployhq/getting-started-with-claude-code-the-ai-coding-assistant-for-your-terminal-4cba"&gt;Getting Started with Claude Code&lt;/a&gt;, &lt;a href="https://dev.to/deployhq/getting-started-with-openai-codex-cli-ai-powered-code-generation-from-your-terminal-5hm8"&gt;Getting Started with OpenAI Codex CLI&lt;/a&gt;, &lt;a href="https://dev.to/deployhq/getting-started-with-google-gemini-cli-open-source-ai-agent-for-your-terminal-25e1"&gt;Getting Started with Google Gemini CLI&lt;/a&gt;, and &lt;a href="https://www.deployhq.com/blog/comparing-ai-cli-coding-assistants-claude-code-vs-codex-vs-gemini-cli" rel="noopener noreferrer"&gt;Comparing AI CLI Coding Assistants&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Your AI coding agent can read files, run commands, and edit code. But how does it actually connect to the tools and services it needs? In 2026, there are two dominant approaches: &lt;strong&gt;command-line interfaces (CLIs)&lt;/strong&gt; and the &lt;strong&gt;Model Context Protocol (MCP)&lt;/strong&gt;. Both give coding agents access to external capabilities — but they work in fundamentally different ways, with real consequences for context quality, reliability, and what your agent can actually do.&lt;/p&gt;

&lt;p&gt;This isn't an abstract protocol debate. The interface you choose directly affects how well your agent understands your codebase, how accurately it executes tasks, and how much manual oversight you need to provide. Let's break down what's similar, what's different, and when each approach makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Mean by CLIs and MCPs
&lt;/h2&gt;

&lt;p&gt;Before comparing them, let's be precise about what each term means in the context of coding agents.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLIs: The Agent Shells Out
&lt;/h3&gt;

&lt;p&gt;When a coding agent uses a CLI tool, it constructs a shell command, executes it in a subprocess, and parses the text output. This is the oldest and most universal integration pattern. Your agent might run &lt;code&gt;git log --oneline -10&lt;/code&gt; to see recent commits, &lt;code&gt;npm test&lt;/code&gt; to run your test suite, or &lt;code&gt;curl&lt;/code&gt; to hit an API endpoint.&lt;/p&gt;

&lt;p&gt;The agent treats the CLI as a black box: it sends a string, gets a string back, and interprets the result using its language understanding.&lt;/p&gt;

&lt;h3&gt;
  
  
  MCP: A Structured Tool Protocol
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; is an open standard that defines a JSON-RPC interface between AI models and external tools. Instead of executing arbitrary shell commands, the agent calls named tools with typed parameters and receives structured JSON responses.&lt;/p&gt;

&lt;p&gt;An MCP server might expose a &lt;code&gt;search_posts&lt;/code&gt; tool that accepts &lt;code&gt;{ "query": "deployment", "status": "published" }&lt;/code&gt; and returns a typed array of post objects — complete with IDs, titles, dates, and metadata. The agent never needs to parse text output or guess at field meanings.&lt;/p&gt;

&lt;h2&gt;
  
  
  What They Have in Common
&lt;/h2&gt;

&lt;p&gt;Despite the architectural differences, CLIs and MCP share several characteristics:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both extend agent capabilities.&lt;/strong&gt; Whether your agent runs &lt;code&gt;gh pr list&lt;/code&gt; or calls an MCP tool named &lt;code&gt;list_pull_requests&lt;/code&gt;, the end result is the same: the agent gains access to information and actions beyond its training data. Both approaches turn a conversational AI into something that can interact with real systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both require trust boundaries.&lt;/strong&gt; A CLI command can delete files; an MCP tool can publish a blog post. In both cases, you need permission models. Claude Code uses approval prompts for shell commands; MCP servers define their own tool permissions. The security concern is identical — you're giving an AI agent the ability to affect real systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both work across all major coding agents.&lt;/strong&gt; Claude Code, Codex CLI, and Gemini CLI all support both shell execution and MCP servers. (If you're still choosing between these three, our &lt;a href="https://www.deployhq.com/blog/comparing-ai-cli-coding-assistants-claude-code-vs-codex-vs-gemini-cli" rel="noopener noreferrer"&gt;comparison of AI CLI coding assistants&lt;/a&gt; covers the differences in detail.) The specific configuration differs, but the concept is universal. If you're building an integration, either approach will work with the tools your team already uses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both can be composed.&lt;/strong&gt; Agents regularly chain multiple CLI commands together (&lt;code&gt;git add . &amp;amp;&amp;amp; git commit -m "fix" &amp;amp;&amp;amp; git push&lt;/code&gt;), and they can chain multiple MCP tool calls in sequence. Complex workflows are possible with either approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where They Differ
&lt;/h2&gt;

&lt;p&gt;Here's where the comparison gets interesting — and where the choice actually matters for your development workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Output Structure: Text vs. Typed Data
&lt;/h3&gt;

&lt;p&gt;This is the single biggest difference, and it cascades into nearly every other comparison point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLI output is unstructured text.&lt;/strong&gt; When your agent runs &lt;code&gt;ls -la&lt;/code&gt;, it gets a blob of text that it must parse using pattern matching and language understanding. This works remarkably well for simple commands, but it introduces ambiguity. Does that column represent file size or block count? Is that date in DD/MM or MM/DD format? The agent is constantly inferring structure from text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP responses are structured JSON.&lt;/strong&gt; When your agent calls an MCP tool, it gets back typed fields with known semantics. A post object has a &lt;code&gt;published_at&lt;/code&gt; field that's always a Unix timestamp, a &lt;code&gt;title&lt;/code&gt; that's always a string, and a &lt;code&gt;categories&lt;/code&gt; array that's always a list of IDs. There's no ambiguity to resolve.&lt;/p&gt;

&lt;p&gt;This matters most when agents need to make decisions based on the data. An agent parsing &lt;code&gt;git log&lt;/code&gt; output might misinterpret a commit message that contains a date-like string. An agent receiving structured commit objects from a Git MCP server won't have that problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Discoverability: Man Pages vs. Tool Schemas
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;CLI tools require the agent to know what exists.&lt;/strong&gt; The agent needs prior knowledge (from training data) about which commands are available, what flags they accept, and what their output looks like. If you have a custom deployment script at &lt;code&gt;./scripts/deploy.sh&lt;/code&gt;, the agent won't know about it unless you tell it — or it happens to find it while exploring your project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP servers declare their capabilities.&lt;/strong&gt; When an agent connects to an MCP server, it receives a complete list of available tools with descriptions, parameter schemas, and return types. The agent knows exactly what it can do without any prior knowledge. This is especially powerful for domain-specific tools — your custom &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; MCP server doesn't need to be in the model's training data for the agent to use it effectively.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Context Efficiency: Tokens Matter
&lt;/h3&gt;

&lt;p&gt;Every piece of information the agent processes consumes tokens from its context window. This has real cost and performance implications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLI output is verbose and unstructured.&lt;/strong&gt; Running &lt;code&gt;docker ps&lt;/code&gt; might return 20 lines of formatted table output when the agent only needed the container ID and status. The agent ingests all of it. Multiply this across dozens of commands in a complex task, and you're burning significant context on formatting, headers, and irrelevant columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP responses are precise.&lt;/strong&gt; An MCP tool can return exactly the fields the agent needs. If you only want post titles and IDs, the MCP server returns just that — no extra columns, no formatting overhead, no ASCII table borders. For agents working on long, multi-step tasks, this efficiency compounds significantly.&lt;/p&gt;

&lt;p&gt;Here's a concrete example. Fetching the last 10 blog posts via CLI might look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.example.com/posts?limit&lt;span class="o"&gt;=&lt;/span&gt;10 | jq &lt;span class="s1"&gt;'.[] | {id, title, status}'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That raw JSON response might be 2,000 tokens. The equivalent MCP call returns the same data in a pre-structured format that the agent can consume in roughly 800 tokens — because there's no HTTP headers, no &lt;code&gt;jq&lt;/code&gt; processing, and no raw response wrapping.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Error Handling: Exit Codes vs. Typed Errors
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;CLI errors are inconsistent.&lt;/strong&gt; Some tools use exit codes, some print to stderr, some return error messages on stdout, and some do all three in different combinations. An agent parsing CLI output needs to handle all these patterns — and sometimes the &lt;q&gt;error&lt;/q&gt; is actually a warning that doesn't indicate failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP errors follow a standard format.&lt;/strong&gt; The protocol defines error responses with codes, messages, and optional details. The agent always knows whether a call succeeded or failed, and can make reliable decisions about retry logic, fallback strategies, or error reporting.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Security Model: Sandbox vs. Scoped Permissions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;CLI access is broad.&lt;/strong&gt; When you give an agent shell access, it can potentially run any command your user account can execute. Most coding agents mitigate this with approval prompts, but the underlying capability is unrestricted. A malicious or confused agent could run &lt;code&gt;rm -rf /&lt;/code&gt; if the permission check fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP access is scoped by design.&lt;/strong&gt; Each MCP server exposes only its specific tools. A blog management MCP server can't access your filesystem; a database MCP server can't run shell commands. The attack surface is naturally smaller. If an MCP server's token has read-only permissions, no amount of clever prompting can make it write data.&lt;/p&gt;

&lt;p&gt;This becomes especially important when agents operate with reduced oversight — in automated pipelines, background tasks, or &lt;q&gt;full auto&lt;/q&gt; modes.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. State and Authentication
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;CLIs inherit the user's environment.&lt;/strong&gt; Your shell's PATH, environment variables, SSH keys, and authentication tokens are all available to CLI commands. This is convenient — &lt;code&gt;git push&lt;/code&gt; just works because your SSH agent is running — but it also means the agent has access to all your credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP servers manage their own auth.&lt;/strong&gt; Each server handles its own authentication, typically through API tokens or service accounts configured when the server starts. This creates clear boundaries: your Google Search Console MCP server has its own service account credentials that are separate from your shell environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;CLI&lt;/th&gt;
&lt;th&gt;MCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Output format&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unstructured text&lt;/td&gt;
&lt;td&gt;Typed JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Discoverability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Requires prior knowledge&lt;/td&gt;
&lt;td&gt;Self-describing schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Context efficiency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Verbose (high token cost)&lt;/td&gt;
&lt;td&gt;Precise (low token cost)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Error handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Inconsistent across tools&lt;/td&gt;
&lt;td&gt;Standardised protocol&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Security scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Broad shell access&lt;/td&gt;
&lt;td&gt;Scoped per server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Inherits user environment&lt;/td&gt;
&lt;td&gt;Isolated per server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Setup complexity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zero (tools already installed)&lt;/td&gt;
&lt;td&gt;Moderate (server config needed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Universality&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Any CLI tool works&lt;/td&gt;
&lt;td&gt;Only MCP-enabled services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ecosystem maturity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Decades of tools&lt;/td&gt;
&lt;td&gt;Growing rapidly (2024+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Offline capability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Depends on server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which Provides Better Context for Coding Agents?
&lt;/h2&gt;

&lt;p&gt;If you're optimising for agent performance — fewer hallucinations, more accurate tool use, better multi-step reasoning — MCP has a clear structural advantage. Typed data, self-describing schemas, and efficient token usage all contribute to higher-quality agent behaviour.&lt;/p&gt;

&lt;p&gt;But that doesn't mean you should replace all CLIs with MCP servers. The practical answer is more nuanced:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use MCP for domain-specific integrations.&lt;/strong&gt; If you regularly interact with a specific service — your blog platform, deployment tool, monitoring system, or database — an MCP server gives the agent richer context and more reliable interactions. This is why tools like the &lt;a href="https://www.deployhq.com/blog/mcp-servers-every-web-developer-needs-in-2026" rel="noopener noreferrer"&gt;DeployHQ MCP server&lt;/a&gt; exist: they provide structured access to deployment management that no CLI could match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use CLIs for general-purpose operations.&lt;/strong&gt; File system operations, Git commands, package management, and build tools are universal. They work everywhere, require no setup, and agents handle them well. There's no benefit to wrapping &lt;code&gt;npm install&lt;/code&gt; in an MCP server — the CLI works perfectly for this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use both together.&lt;/strong&gt; The most effective setup in 2026 combines both approaches. Your agent uses CLIs for general development tasks and MCP servers for specialised integrations. Claude Code, for example, natively supports both — you can run shell commands and call MCP tools in the same conversation, letting the agent choose the best approach for each step.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for Your Deployment Workflow
&lt;/h2&gt;

&lt;p&gt;For teams using &lt;a href="https://www.deployhq.com/features/automatic-deployments" rel="noopener noreferrer"&gt;automated Git deployments&lt;/a&gt;, the CLI-vs-MCP choice has practical implications:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Git operations stay CLI-based.&lt;/strong&gt; Your agent commits, pushes, and manages branches through standard Git commands. This is well-understood, universal, and effective.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment management benefits from MCP.&lt;/strong&gt; Checking deployment status, triggering rollbacks, or inspecting server configurations are tasks where structured data matters. An MCP server that returns deployment status as &lt;code&gt;{ "status": "success", "deployed_at": "2026-04-12T10:30:00Z", "commit": "abc1234" }&lt;/code&gt; gives the agent far better context than parsing HTML or text output from a dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content operations are ideal for MCP.&lt;/strong&gt; If your workflow includes managing blog content alongside code — writing deployment guides, updating documentation, managing SEO — an MCP server provides the structured access that makes agents genuinely useful for content workflows.&lt;/p&gt;

&lt;p&gt;The pattern we see with teams using &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; is straightforward: Git CLI for code, MCP for everything else that has an API. This gives agents the broadest capabilities with the best context quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Future: Convergence
&lt;/h2&gt;

&lt;p&gt;The boundary between CLIs and MCP is already blurring. Some newer CLI tools output structured JSON by default. MCP servers can wrap existing CLIs to add structure. And coding agents are getting better at parsing unstructured output.&lt;/p&gt;

&lt;p&gt;But the trend is clear: as agents take on more complex, multi-step tasks with less human oversight, structured interfaces become more important — not less. The more an agent operates autonomously, the more it benefits from unambiguous, typed data.&lt;/p&gt;

&lt;p&gt;If you're building tools for AI agents today, MCP is worth the investment. If you're using agents for development work, configure both CLIs and the MCP servers that match your workflow. The agents that perform best are the ones with the richest, most structured context available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I use MCP and CLIs together in the same agent session?
&lt;/h3&gt;

&lt;p&gt;Yes. All major coding agents — Claude Code, Codex CLI, and Gemini CLI — support both shell commands and MCP tool calls in the same session. The agent picks the most appropriate interface for each task automatically. You don't need to choose one or the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do MCP servers replace the need for CLI tools?
&lt;/h3&gt;

&lt;p&gt;No. MCP servers complement CLIs rather than replacing them. CLIs excel at general-purpose operations like file management, Git, and build tools. MCP servers are best for domain-specific services where structured data and scoped permissions matter. The most effective setups use both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is MCP harder to set up than just using CLIs?
&lt;/h3&gt;

&lt;p&gt;MCP requires initial configuration — you need to install and configure each server, provide API tokens, and register them with your coding agent. CLIs, by contrast, typically just work with your existing shell environment. However, the setup is usually a one-time task, and the improved agent performance often justifies the upfront effort.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does using MCP reduce token costs?
&lt;/h3&gt;

&lt;p&gt;Generally yes. MCP responses are structured and concise, which means less token usage per interaction compared to parsing verbose CLI output. For agents running long, multi-step tasks or operating at scale, this efficiency can meaningfully reduce costs — especially with pay-per-token pricing models.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which approach is more secure?
&lt;/h3&gt;

&lt;p&gt;MCP has a structural security advantage because each server exposes only specific capabilities with scoped permissions. CLI access grants broader system access by default. However, both approaches require proper configuration — MCP servers need secure token management, and CLI access needs appropriate permission prompts. Neither is inherently &lt;q&gt;safe&lt;/q&gt; without proper setup.&lt;/p&gt;




&lt;p&gt;AI coding agents are most effective when they have the right context, delivered through the right interface. For general development tasks, your terminal's CLI tools remain indispensable. For specialised integrations — content management, &lt;a href="https://www.deployhq.com/features/automatic-deployments" rel="noopener noreferrer"&gt;deployment automation&lt;/a&gt;, monitoring, and APIs — MCP provides the structured context that helps agents work accurately and efficiently.&lt;/p&gt;

&lt;p&gt;The best setup isn't one or the other. It's both, configured to match your workflow.&lt;/p&gt;

&lt;p&gt;Ready to add structured deployment management to your AI coding workflow? &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; integrates with all major AI coding assistants through both CLI and MCP, giving your agents the context they need to manage deployments confidently. &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Get started for free&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For questions or feedback, reach out at &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or on &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;Twitter/X&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devopsinfrastructure</category>
      <category>cli</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Deploy from Gitea to Your Server Automatically</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Sat, 11 Apr 2026 07:22:39 +0000</pubDate>
      <link>https://dev.to/deployhq/deploy-from-gitea-to-your-server-automatically-1dp0</link>
      <guid>https://dev.to/deployhq/deploy-from-gitea-to-your-server-automatically-1dp0</guid>
      <description>&lt;p&gt;Gitea is quietly becoming the go-to self-hosted Git service. It's lightweight (runs on a Raspberry Pi), easy to install, and gives you full control over your source code without depending on GitHub or GitLab's infrastructure.&lt;/p&gt;

&lt;p&gt;But Gitea has one notable gap: &lt;strong&gt;no built-in CI/CD&lt;/strong&gt;. Gitea Actions exists but is still maturing and requires a separate runner instance. For teams that just want to push code and have it deployed to their server, that's a lot of infrastructure for a simple requirement.&lt;/p&gt;

&lt;p&gt;Here's how to add fully automated deployments to your Gitea workflow in under 10 minutes, using webhooks and &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Gitea Needs an External Deployment Tool
&lt;/h2&gt;

&lt;p&gt;Gitea is intentionally minimal — it's a Git hosting service, not a DevOps platform. That's a feature, not a bug. But it means deployment is left as an exercise for the user.&lt;/p&gt;

&lt;p&gt;The common approaches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gitea Actions&lt;/strong&gt; — still in development, requires a dedicated runner, modelled after GitHub Actions but with fewer marketplace actions available&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drone CI / Woodpecker CI&lt;/strong&gt; — full CI/CD systems that integrate with Gitea, but they're heavy for simple &lt;q&gt;push to deploy&lt;/q&gt; workflows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual SSH&lt;/strong&gt; — &lt;code&gt;ssh server 'cd /var/www &amp;amp;&amp;amp; git pull'&lt;/code&gt; works but breaks the moment you need build commands or multiple servers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook-based deployment&lt;/strong&gt; — a lightweight service watches for push events and deploys automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The webhook approach hits the sweet spot: no CI infrastructure to maintain, no YAML pipelines to write, and it works with any Gitea installation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Developer] --&amp;gt;|git push| B[Gitea Instance]
    B --&amp;gt;|webhook POST| C[DeployHQ]
    C --&amp;gt;|git clone via SSH| B
    C --&amp;gt;|build commands| D[Build Server]
    D --&amp;gt;|changed files via SSH/SFTP| E[Production Server]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you push to Gitea, a webhook fires. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; receives the notification, clones the latest code from your Gitea repo, runs any build commands, and deploys the changed files to your server. The entire cycle takes seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-Step Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Create a DeployHQ Project
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Sign up for DeployHQ&lt;/a&gt; (free for one project) and create a new project.&lt;/p&gt;

&lt;p&gt;When asked for your repository, choose &lt;strong&gt;&lt;q&gt;Enter repository URL manually&lt;/q&gt;&lt;/strong&gt; — Gitea isn't in the OAuth provider list since it's self-hosted.&lt;/p&gt;

&lt;p&gt;Enter your Gitea repository URL. You have two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSH (recommended):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git@gitea.yourserver.com:username/repo.git

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;HTTPS with access token:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://gitea.yourserver.com/username/repo.git

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For SSH, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; will generate an SSH key pair. Copy the public key.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Add the SSH Key to Gitea
&lt;/h3&gt;

&lt;p&gt;In your Gitea repository:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Deploy Keys&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;Deploy&lt;/a&gt; Key&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Paste the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; public key&lt;/li&gt;
&lt;li&gt;Title it &lt;q&gt;DeployHQ&lt;/q&gt;
&lt;/li&gt;
&lt;li&gt;Leave &lt;q&gt;Enable Write Access&lt;/q&gt; unchecked (read-only is sufficient)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Alternatively, add the key to your Gitea user account under &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;SSH/GPG Keys&lt;/strong&gt; for access to all your repositories.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Configure Your Server in DeployHQ
&lt;/h3&gt;

&lt;p&gt;Add your deployment target:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Protocol:&lt;/strong&gt; SSH/SFTP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hostname:&lt;/strong&gt; your-production-server.com&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Username:&lt;/strong&gt; your deploy user&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication:&lt;/strong&gt; SSH key (add DeployHQ's key to the server's &lt;code&gt;authorized_keys&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment Path:&lt;/strong&gt; &lt;code&gt;/var/www/myapp/&lt;/code&gt; (or your web root)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Set Up Build Commands (If Needed)
&lt;/h3&gt;

&lt;p&gt;If your project needs a build step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight batchfile"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;%path%&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kd"&gt;npm&lt;/span&gt; &lt;span class="kd"&gt;ci&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kd"&gt;npm&lt;/span&gt; &lt;span class="nb"&gt;run&lt;/span&gt; &lt;span class="kd"&gt;build&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the &lt;strong&gt;Deployment Subdirectory&lt;/strong&gt; if you're deploying build output (e.g., &lt;code&gt;dist/&lt;/code&gt; for static sites, &lt;code&gt;build/&lt;/code&gt; for React apps).&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Configure the Webhook in Gitea
&lt;/h3&gt;

&lt;p&gt;This is where Gitea and &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; connect. In &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, go to your project and find the &lt;strong&gt;Webhook URL&lt;/strong&gt; (under project settings or on the servers page).&lt;/p&gt;

&lt;p&gt;In Gitea:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to your repository → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Webhooks&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add Webhook&lt;/strong&gt; → choose &lt;strong&gt;Gitea&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Target URL:&lt;/strong&gt; paste the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; webhook URL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP Method:&lt;/strong&gt; POST&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content Type:&lt;/strong&gt; &lt;code&gt;application/json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger On:&lt;/strong&gt; Push Events&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active:&lt;/strong&gt; checked&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add Webhook&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Test it by clicking the &lt;strong&gt;Test Delivery&lt;/strong&gt; button. You should see a 200 response.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Push and Deploy
&lt;/h3&gt;

&lt;p&gt;Make a change, commit, and push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; README.md
git add &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Test deployment"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git push

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gitea fires the webhook → &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; clones, builds, and deploys. Check the deployment log in &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; to see exactly which files were transferred.&lt;/p&gt;

&lt;h2&gt;
  
  
  Branch-Based Deployments
&lt;/h2&gt;

&lt;p&gt;Map different branches to different servers for a staging/production workflow:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;production-server.com&lt;/td&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;develop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;staging-server.com&lt;/td&gt;
&lt;td&gt;Staging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;feature/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Not deployed (review locally)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, add multiple servers and assign each to a specific branch. Pushing to &lt;code&gt;develop&lt;/code&gt; deploys to staging. Merging to &lt;code&gt;main&lt;/code&gt; deploys to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Self-Signed Certificates
&lt;/h2&gt;

&lt;p&gt;If your Gitea instance uses a self-signed HTTPS certificate, DeployHQ's HTTPS clone will fail certificate verification. Two solutions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use SSH instead of HTTPS&lt;/strong&gt; — SSH cloning doesn't involve TLS certificates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a real certificate&lt;/strong&gt; — Let's Encrypt is free and works with any domain&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;SSH is the simpler fix and is recommended regardless of certificate situation — it's faster and avoids password/token management.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gitea Behind a Firewall
&lt;/h2&gt;

&lt;p&gt;If your Gitea instance isn't publicly accessible, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; can't reach it for webhook delivery or Git cloning. Options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open port 22 (SSH) to DeployHQ's IPs&lt;/strong&gt; — check DeployHQ's documentation for the IP list&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a reverse SSH tunnel&lt;/strong&gt; — more complex but avoids opening firewall ports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mirror to a public Git host&lt;/strong&gt; — Gitea can mirror to GitHub/GitLab, and &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; clones from there&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most setups, opening SSH access from DeployHQ's IPs is the simplest approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: Gitea Deployment Options
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Setup time&lt;/th&gt;
&lt;th&gt;Maintenance&lt;/th&gt;
&lt;th&gt;Build support&lt;/th&gt;
&lt;th&gt;Multi-server&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DeployHQ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~10 min&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Yes (npm, Composer, etc.)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Free for 1 project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gitea Actions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;30-60 min&lt;/td&gt;
&lt;td&gt;Runner maintenance&lt;/td&gt;
&lt;td&gt;Yes (via Actions)&lt;/td&gt;
&lt;td&gt;Custom scripting&lt;/td&gt;
&lt;td&gt;Free (self-hosted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Drone CI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;30-60 min&lt;/td&gt;
&lt;td&gt;Server + runners&lt;/td&gt;
&lt;td&gt;Yes (Docker-based)&lt;/td&gt;
&lt;td&gt;Custom config&lt;/td&gt;
&lt;td&gt;Free (OSS edition)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Woodpecker CI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;30-60 min&lt;/td&gt;
&lt;td&gt;Server + agents&lt;/td&gt;
&lt;td&gt;Yes (Docker-based)&lt;/td&gt;
&lt;td&gt;Custom config&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Manual &lt;code&gt;git pull&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;Error-prone&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Custom webhook script&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1-2 hours&lt;/td&gt;
&lt;td&gt;Script maintenance&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; wins on setup time and maintenance. Drone/Woodpecker win if you need full CI (testing, multi-stage pipelines, matrix builds). Gitea Actions will likely become the default choice once it matures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Repository Deployments
&lt;/h2&gt;

&lt;p&gt;If your project spans multiple Gitea repositories (frontend + backend, or a main app + shared library), create separate &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; projects for each. Each project has its own webhook, build configuration, and server targets.&lt;/p&gt;

&lt;p&gt;For monorepos on Gitea, use DeployHQ's deployment subdirectory feature to deploy specific directories from the repository.&lt;/p&gt;




&lt;p&gt;Gitea gives you independence from hosted Git providers. Adding &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; gives you automated deployments without the complexity of running a full CI/CD system. Push to Gitea, and your code is live — simple as that.&lt;/p&gt;

&lt;p&gt;If you're using &lt;a href="https://www.deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or &lt;a href="https://www.deployhq.com/deploy-from-gitlab" rel="noopener noreferrer"&gt;GitLab&lt;/a&gt; alongside Gitea, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; works with all of them from a single dashboard.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Start deploying from Gitea&lt;/a&gt; — set up takes about 10 minutes.&lt;/p&gt;

&lt;p&gt;Questions about connecting your Gitea instance? Contact &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or reach us on &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;Twitter/X&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>git</category>
      <category>tutorials</category>
      <category>gitea</category>
      <category>deployhq</category>
    </item>
    <item>
      <title>How to Deploy EmDash with DeployHQ</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Thu, 09 Apr 2026 16:28:31 +0000</pubDate>
      <link>https://dev.to/deployhq/how-to-deploy-emdash-with-deployhq-218c</link>
      <guid>https://dev.to/deployhq/how-to-deploy-emdash-with-deployhq-218c</guid>
      <description>&lt;p&gt;EmDash is a full-stack TypeScript CMS built on Astro — often described as the spiritual successor to WordPress. It combines a modern admin interface with Astro's rendering performance, Portable Text for structured content, and passkey-first authentication. If you're building a content-driven site with EmDash and need reliable, repeatable deployments from Git, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; makes the process straightforward.&lt;/p&gt;

&lt;p&gt;In this guide, you'll set up an EmDash project for Node.js deployment, connect it to &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, configure your build pipeline, and deploy to a VPS or cloud server with zero manual file transfers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is EmDash?
&lt;/h2&gt;

&lt;p&gt;EmDash is an open-source CMS that runs as an &lt;a href="https://astro.build/" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; integration. Unlike traditional CMSs that bolt a content API onto a separate frontend, EmDash ships everything in one project — the admin panel, content editor, authentication, media management, and your frontend templates.&lt;/p&gt;

&lt;p&gt;A few things that set EmDash apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astro-native&lt;/strong&gt; : Your site is an Astro project. EmDash adds the CMS layer as an integration, so you keep full control over routing, components, and rendering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portable Text editor&lt;/strong&gt; : Content is stored as structured JSON (via TipTap), not raw HTML. This makes content reusable across different presentation formats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passkey-first auth&lt;/strong&gt; : No passwords by default. Users authenticate with WebAuthn passkeys, with OAuth and magic links as fallbacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema-in-database&lt;/strong&gt; : Content collections are defined in the database, not in code files. This means editors can create new collections without touching the codebase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugin system&lt;/strong&gt; : Extensible via a &lt;code&gt;definePlugin()&lt;/code&gt; API with sandboxed execution on Cloudflare or in-process on Node.js.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;EmDash supports multiple deployment targets — Cloudflare Workers, Docker containers, or plain Node.js servers. This guide focuses on Node.js deployment via &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, which covers VPS hosting, cloud instances, and dedicated servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before starting, make sure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 22.12.0 or later&lt;/strong&gt; installed on both your local machine and your deployment server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Git repository&lt;/strong&gt; (GitHub, GitLab, or Bitbucket) containing your EmDash project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; account&lt;/strong&gt; — you can &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;sign up for a free trial&lt;/a&gt; if you don't have one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH access to your server&lt;/strong&gt; with Node.js 22+ installed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A process manager&lt;/strong&gt; like PM2 or systemd configured on your server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you haven't created an EmDash project yet, scaffold one with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm create emdash@latest my-site
&lt;span class="nb"&gt;cd &lt;/span&gt;my-site
npm &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates an Astro project with EmDash pre-configured. The scaffolder lets you choose from blog, marketing, or portfolio templates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preparing EmDash for Production
&lt;/h2&gt;

&lt;p&gt;EmDash requires server-side rendering — it's a full-stack CMS, not a static site generator. Your Astro config must use the Node.js adapter in standalone mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configure the Astro Adapter
&lt;/h3&gt;

&lt;p&gt;Open your &lt;code&gt;astro.config.mjs&lt;/code&gt; and verify the Node.js adapter is set to &lt;code&gt;standalone&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// astro.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@astrojs/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;emdash&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;emdash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;node&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standalone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;emdash&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;standalone&lt;/code&gt; mode is critical — it produces a self-contained server entry point at &lt;code&gt;./dist/server/entry.mjs&lt;/code&gt; that you can run directly with Node.js. The alternative &lt;code&gt;middleware&lt;/code&gt; mode is for platforms like Express where you manage the HTTP server yourself, but standalone is simpler for &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deployments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generate Authentication Secrets
&lt;/h3&gt;

&lt;p&gt;EmDash needs two secrets for production: one for signing session cookies and another for preview URLs. Generate them locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx emdash auth secret

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This outputs a cryptographically secure random string. Run it twice — once for &lt;code&gt;EMDASH_AUTH_SECRET&lt;/code&gt; and once for &lt;code&gt;EMDASH_PREVIEW_SECRET&lt;/code&gt;. Save both values; you'll add them as environment variables in &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choose Your Database
&lt;/h3&gt;

&lt;p&gt;EmDash supports several database backends. For a Node.js deployment via &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, the practical options are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;Single-server deployments&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;DATABASE_PATH&lt;/code&gt; to a persistent directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;Multi-server or production-critical sites&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;DATABASE_URL&lt;/code&gt; connection string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;libSQL (Turso)&lt;/td&gt;
&lt;td&gt;Remote SQLite with replication&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;LIBSQL_DATABASE_URL&lt;/code&gt; and &lt;code&gt;LIBSQL_AUTH_TOKEN&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SQLite is the simplest choice for a single VPS — no external database server needed. Just make sure the database file lives on a persistent volume, not in the deployment directory (since &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; replaces files on each deploy).&lt;/p&gt;

&lt;p&gt;For SQLite, create a data directory outside your deployment path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On your server, create a persistent data directory&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/data/emdash
&lt;span class="nb"&gt;chown &lt;/span&gt;deploy:deploy /var/data/emdash

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set the environment variable to point there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;DATABASE_PATH&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/data/emdash/emdash.db&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Add a Start Script
&lt;/h3&gt;

&lt;p&gt;Verify your &lt;code&gt;package.json&lt;/code&gt; includes a start script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro build"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node ./dist/server/entry.mjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bootstrap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"emdash init &amp;amp;&amp;amp; emdash seed"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;build&lt;/code&gt; command compiles your Astro project into the &lt;code&gt;dist/&lt;/code&gt; directory. The &lt;code&gt;start&lt;/code&gt; command runs the compiled server. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; will execute &lt;code&gt;build&lt;/code&gt; during deployment and your process manager will run &lt;code&gt;start&lt;/code&gt; on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up DeployHQ
&lt;/h2&gt;

&lt;p&gt;With your EmDash project in Git and your server ready, connect everything through &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create a New Project
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Log in to &lt;a href="https://www.deployhq.com/" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; and click &lt;strong&gt;New Project&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Name it (e.g., &lt;q&gt;EmDash Blog&lt;/q&gt;) and select the server region closest to your deployment target&lt;/li&gt;
&lt;li&gt;Connect your Git repository — &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; supports GitHub, GitLab, Bitbucket, and self-hosted Git servers&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Configure the Server
&lt;/h3&gt;

&lt;p&gt;Add your deployment server under &lt;strong&gt;Servers&lt;/strong&gt; in the project settings:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;New Server&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;SSH/SFTP&lt;/strong&gt; as the protocol&lt;/li&gt;
&lt;li&gt;Enter your server hostname, SSH port, and the deploy user credentials&lt;/li&gt;
&lt;li&gt;Set the &lt;strong&gt;Deployment Path&lt;/strong&gt; to where your application lives, for example &lt;code&gt;/var/www/emdash&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Test the connection to verify &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; can reach your server&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Configure the Build Pipeline
&lt;/h3&gt;

&lt;p&gt;This is where &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; handles your Node.js build. Navigate to &lt;strong&gt;Build Pipeline&lt;/strong&gt; in your project settings:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enable the &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipeline&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Node.js 22&lt;/strong&gt; as the build environment&lt;/li&gt;
&lt;li&gt;Add the build commands:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm ci
npm run build

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;npm ci&lt;/code&gt; instead of &lt;code&gt;npm install&lt;/code&gt; ensures a clean, reproducible install from your lockfile — important for consistent deployments.&lt;/p&gt;

&lt;p&gt;DeployHQ's build pipeline runs these commands in an isolated container, compiles your Astro project, and then deploys only the output files to your server. This means your server doesn't need to run &lt;code&gt;npm install&lt;/code&gt; or &lt;code&gt;npm run build&lt;/code&gt; — it just receives the compiled application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Set Environment Variables
&lt;/h3&gt;

&lt;p&gt;Under &lt;strong&gt;Environment Variables&lt;/strong&gt; in your project settings, add:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EMDASH_AUTH_SECRET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your generated auth secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EMDASH_PREVIEW_SECRET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your generated preview secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DATABASE_PATH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/var/data/emdash/emdash.db&lt;/code&gt; (for SQLite)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HOST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PORT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;4321&lt;/code&gt; (or your preferred port)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're using PostgreSQL instead, replace &lt;code&gt;DATABASE_PATH&lt;/code&gt; with &lt;code&gt;DATABASE_URL&lt;/code&gt; pointing to your connection string.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; encrypts environment variables at rest, so your secrets are protected. These variables are available during the build step and can also be written to a &lt;code&gt;.env&lt;/code&gt; file on the server if your application reads from one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configure Deployment Exclusions
&lt;/h3&gt;

&lt;p&gt;Not every file needs to reach your server. Under &lt;strong&gt;Excluded Files&lt;/strong&gt; , add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
tests/
node_modules/
.git/
tsconfig.json
astro.config.mjs
README.md

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait — you actually need &lt;code&gt;node_modules&lt;/code&gt; on the server because &lt;code&gt;dist/server/entry.mjs&lt;/code&gt; requires runtime dependencies. There are two approaches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach A — &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;Deploy&lt;/a&gt; node_modules (simpler):&lt;/strong&gt; Don't exclude &lt;code&gt;node_modules&lt;/code&gt;. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; transfers the full project including dependencies. This is slower but requires no server-side commands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach B — Install on server (cleaner):&lt;/strong&gt; Exclude &lt;code&gt;node_modules&lt;/code&gt; and add a &lt;a href="https://www.deployhq.com/support/projects-and-%E0%AE%9Fservers/ssh-commands" rel="noopener noreferrer"&gt;post-deploy SSH command&lt;/a&gt; to install production dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/emdash &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Approach B is preferred for larger projects because it only installs production dependencies, keeping the server lean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying EmDash
&lt;/h2&gt;

&lt;h3&gt;
  
  
  First Deployment
&lt;/h3&gt;

&lt;p&gt;Your first deployment needs extra setup because the database doesn't exist yet. After the initial deploy completes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SSH into your server&lt;/li&gt;
&lt;li&gt;Navigate to your deployment directory&lt;/li&gt;
&lt;li&gt;Run the bootstrap command to initialise the database:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/emdash
npm run bootstrap

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates the database schema and optionally seeds sample content. You only need to run this once.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start the application:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Using PM2 (recommended)&lt;/span&gt;
pm2 start dist/server/entry.mjs &lt;span class="nt"&gt;--name&lt;/span&gt; emdash

&lt;span class="c"&gt;# Or using systemd (see next section)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Visit &lt;code&gt;http://your-server:4321/_emdash/admin&lt;/code&gt; to complete the setup wizard — set your site title, tagline, and register your admin passkey.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Set Up a Process Manager
&lt;/h3&gt;

&lt;p&gt;For production, use PM2 or systemd to keep EmDash running and restart it on crashes or reboots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PM2 ecosystem file&lt;/strong&gt; (&lt;code&gt;ecosystem.config.cjs&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ecosystem.config.cjs&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;apps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;emdash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./dist/server/entry.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;HOST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.0.0.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4321&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_PATH&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/var/data/emdash/emdash.db&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_memory_restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;512M&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: if you're using SQLite, keep &lt;code&gt;instances: 1&lt;/code&gt;. SQLite doesn't handle concurrent writes from multiple processes well. For multi-instance deployments, switch to PostgreSQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;systemd service&lt;/strong&gt; (&lt;code&gt;/etc/systemd/system/emdash.service&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;EmDash CMS&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;deploy&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/www/emdash&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/node dist/server/entry.mjs&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;NODE_ENV=production&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;HOST=0.0.0.0&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;PORT=4321&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;DATABASE_PATH=/var/data/emdash/emdash.db&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/www/emdash/.env&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Automate Restarts with Post-Deploy Commands
&lt;/h3&gt;

&lt;p&gt;After each deployment, EmDash needs to restart so it picks up the new code. Add a post-deploy SSH command in &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; under &lt;strong&gt;SSH Commands → After Deployment&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/emdash &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pm2 restart emdash

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or for systemd:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart emdash

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures every successful deployment automatically restarts the application without manual intervention.&lt;/p&gt;

&lt;h3&gt;
  
  
  Subsequent Deployments
&lt;/h3&gt;

&lt;p&gt;After the initial setup, the deployment workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Push code changes to your Git repository&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; detects the push (via webhook) and starts a deployment&lt;/li&gt;
&lt;li&gt;The build pipeline runs &lt;code&gt;npm ci &amp;amp;&amp;amp; npm run build&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Compiled files are transferred to your server&lt;/li&gt;
&lt;li&gt;The post-deploy command restarts the application&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can also configure &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; to &lt;a href="https://www.deployhq.com/features/automatic-deployments" rel="noopener noreferrer"&gt;deploy automatically on every push&lt;/a&gt; to your main branch, or keep deployments manual and trigger them from the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment Architecture
&lt;/h2&gt;

&lt;p&gt;Here's how the pieces fit together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Git Push] --&amp;gt; B[DeployHQ]
    B --&amp;gt; C[Build Pipeline&amp;lt;br/&amp;gt;npm ci + astro build]
    C --&amp;gt; D[Transfer Files&amp;lt;br/&amp;gt;via SSH]
    D --&amp;gt; E[VPS / Cloud Server]
    E --&amp;gt; F[PM2 / systemd&amp;lt;br/&amp;gt;Restart]
    F --&amp;gt; G[EmDash Running&amp;lt;br/&amp;gt;port 4321]
    G --&amp;gt; H[Reverse Proxy&amp;lt;br/&amp;gt;nginx / Caddy]
    H --&amp;gt; I[Public Traffic&amp;lt;br/&amp;gt;HTTPS on port 443]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; handles steps A through F. You configure the reverse proxy once during initial server setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up a Reverse Proxy
&lt;/h2&gt;

&lt;p&gt;In production, you'll want a reverse proxy in front of EmDash to handle HTTPS, compression, and static asset caching. Here's a minimal nginx configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;your-domain.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/your-domain.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/your-domain.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:4321&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;'upgrade'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_cache_bypass&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you prefer Caddy, the configuration is even simpler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your-domain.com {
    reverse_proxy localhost:4321
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caddy handles TLS certificates automatically via Let's Encrypt — no manual certificate setup needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Build Fails with &lt;q&gt;Cannot find module&lt;/q&gt; Errors
&lt;/h3&gt;

&lt;p&gt;This usually means dependencies aren't installed correctly. Make sure your &lt;code&gt;package-lock.json&lt;/code&gt; is committed to Git. DeployHQ's build pipeline runs &lt;code&gt;npm ci&lt;/code&gt;, which requires a lockfile. If you're using pnpm locally, either switch &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; to use &lt;code&gt;pnpm install &amp;amp;&amp;amp; pnpm build&lt;/code&gt; or add a &lt;code&gt;package-lock.json&lt;/code&gt; alongside.&lt;/p&gt;

&lt;h3&gt;
  
  
  Application Starts but Admin Panel Returns 404
&lt;/h3&gt;

&lt;p&gt;EmDash's admin routes live under &lt;code&gt;/_emdash/admin&lt;/code&gt;. Make sure your Astro config has &lt;code&gt;output: 'server'&lt;/code&gt; — if it's set to &lt;code&gt;'static'&lt;/code&gt; or &lt;code&gt;'hybrid'&lt;/code&gt;, the server-side routes won't work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database Errors After Deployment
&lt;/h3&gt;

&lt;p&gt;If you're using SQLite and see &lt;q&gt;database is locked&lt;/q&gt; or &lt;q&gt;no such table&lt;/q&gt; errors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verify &lt;code&gt;DATABASE_PATH&lt;/code&gt; points to a directory outside the deployment path (so it persists between deployments)&lt;/li&gt;
&lt;li&gt;Make sure the deploy user has write permissions to the database directory&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;npm run bootstrap&lt;/code&gt; if the database hasn't been initialised yet&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Passkey Authentication Fails
&lt;/h3&gt;

&lt;p&gt;Passkeys require HTTPS. If you're testing over plain HTTP, passkey registration and login will fail. Set up your reverse proxy with TLS first, or use the dev bypass endpoint for initial testing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-domain.com/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove this bypass in production by ensuring &lt;code&gt;NODE_ENV=production&lt;/code&gt; is set.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deployment Succeeds but Site Shows Old Content
&lt;/h3&gt;

&lt;p&gt;The post-deploy restart command might not be running. Check the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deployment log for SSH command output. Also verify that PM2 or systemd is actually restarting the correct process — run &lt;code&gt;pm2 list&lt;/code&gt; or &lt;code&gt;systemctl status emdash&lt;/code&gt; on the server to confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I use DeployHQ's atomic deployments with EmDash?
&lt;/h3&gt;

&lt;p&gt;Yes. DeployHQ's &lt;a href="https://www.deployhq.com/features/zero-downtime-deployments" rel="noopener noreferrer"&gt;zero downtime deployments&lt;/a&gt; work well with EmDash. Each deployment goes into a new directory, and a symlink switches to the new version atomically. Just make sure your &lt;code&gt;DATABASE_PATH&lt;/code&gt; and any uploaded media point to a shared directory outside the release path so they persist across deployments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does EmDash work with DeployHQ's build pipeline caching?
&lt;/h3&gt;

&lt;p&gt;The build pipeline caches &lt;code&gt;node_modules&lt;/code&gt; between deployments, so subsequent builds are faster since npm only installs changed dependencies. Astro's build output isn't cached (it rebuilds each time), but the dependency caching alone can cut build times significantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I deploy EmDash to shared hosting via FTP?
&lt;/h3&gt;

&lt;p&gt;EmDash requires a long-running Node.js process, which most shared hosting plans don't support. You need a VPS, cloud instance, or container hosting where you can run &lt;code&gt;node dist/server/entry.mjs&lt;/code&gt; persistently. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; supports &lt;a href="https://www.deployhq.com/support/projects-and-servers/server-types" rel="noopener noreferrer"&gt;FTP and SFTP deployment&lt;/a&gt; protocols, but the server itself must support Node.js 22+.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I handle database migrations during deployment?
&lt;/h3&gt;

&lt;p&gt;EmDash automatically runs migrations when the application starts (for SQLite, libSQL, and PostgreSQL). There's no separate migration step needed in your deployment pipeline. The restart via PM2 or systemd triggers the migration check on boot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can multiple team members deploy independently?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; supports team collaboration with role-based permissions. You can restrict who can trigger deployments while giving everyone read access to deployment logs. Combined with DeployHQ's &lt;a href="https://www.deployhq.com/features" rel="noopener noreferrer"&gt;deployment approvals&lt;/a&gt;, you can require sign-off before production deployments go live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next Steps
&lt;/h2&gt;

&lt;p&gt;You now have a production EmDash deployment powered by Git-based automation through &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;. Every push to your repository triggers a clean build and deployment — no manual file transfers, no SSH-ing into servers to run commands.&lt;/p&gt;

&lt;p&gt;From here, you might want to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set up &lt;a href="https://www.deployhq.com/support/projects-and-servers/notifications" rel="noopener noreferrer"&gt;deployment notifications&lt;/a&gt; to get Slack or email alerts on each deploy&lt;/li&gt;
&lt;li&gt;Configure a staging server in &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; to test changes before they hit production&lt;/li&gt;
&lt;li&gt;Explore EmDash's plugin system to extend your CMS with custom functionality&lt;/li&gt;
&lt;li&gt;Add a &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipeline&lt;/a&gt; step for running tests before deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to automate your EmDash deployments? &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Sign up for DeployHQ&lt;/a&gt; and connect your repository in minutes.&lt;/p&gt;




&lt;p&gt;If you have questions about deploying EmDash or any other framework with &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, reach out to us at &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or on &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;Twitter/X @deployhq&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>node</category>
      <category>tutorials</category>
      <category>wordpress</category>
      <category>emdash</category>
    </item>
    <item>
      <title>OpenClaw Skills: How to Find, Install, and Build Your Own</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Tue, 07 Apr 2026 06:13:28 +0000</pubDate>
      <link>https://dev.to/deployhq/openclaw-skills-how-to-find-install-and-build-your-own-52aj</link>
      <guid>https://dev.to/deployhq/openclaw-skills-how-to-find-install-and-build-your-own-52aj</guid>
      <description>&lt;p&gt;If you followed our &lt;a href="https://dev.to/theqadiariesforyou/how-to-deploy-and-configure-openclaw-on-a-vps-4h8e-temp-slug-2543902"&gt;guide to deploying OpenClaw on a VPS&lt;/a&gt;, you've got a self-hosted AI assistant running on infrastructure you control. Out of the box it can chat, browse the web, run terminal commands, and remember context across sessions. Skills are what turn it from a capable chatbot into an automation engine that handles the repetitive parts of your workflow without being asked twice.&lt;/p&gt;

&lt;p&gt;This post covers what Skills are, how to find and install them, a tour of the most useful directories, and how to build one from scratch to automate a real task. We'll also walk through troubleshooting common issues and best practices for writing skills that work reliably across environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are Skills?
&lt;/h2&gt;

&lt;p&gt;A Skill is a directory containing a &lt;code&gt;SKILL.md&lt;/code&gt; file — a Markdown document with YAML frontmatter — that teaches OpenClaw a repeatable procedure. When OpenClaw starts, it reads all eligible skills and injects compressed descriptions of them into its system prompt. The agent then knows which skills are available and invokes them automatically when a user request matches.&lt;/p&gt;

&lt;p&gt;The simplest skill is just a natural-language runbook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;skills/
└── my-skill/
    └── SKILL.md

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More advanced skills can include supporting scripts and configuration, but the &lt;code&gt;SKILL.md&lt;/code&gt; is always the entry point. Unlike traditional plugins, which require the host application to define an API surface, Skills work by giving the LLM instructions — the model figures out how to use them in context.&lt;/p&gt;

&lt;p&gt;Three locations are checked when loading skills, in precedence order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;workspace&amp;gt;/skills&lt;/code&gt; — workspace-specific, highest priority&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/.openclaw/skills&lt;/code&gt; — user-level, shared across all agents on the machine&lt;/li&gt;
&lt;li&gt;Built-in bundled skills — shipped with the install, lowest priority&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This means you can override a bundled skill just by dropping a folder with the same name into your workspace directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A["Workspace Skills\n(./skills)"] --&amp;gt;|highest priority| D["OpenClaw\nSystem Prompt"]
    B["User-Level Skills\n(~/.openclaw/skills)"] --&amp;gt;|medium priority| D
    C["Bundled Skills\n(built-in)"] --&amp;gt;|lowest priority| D
    D --&amp;gt; E["Agent Ready\n— skills injected"]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finding Skills: The ClawHub Registry
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/openclaw/clawhub" rel="noopener noreferrer"&gt;ClawHub&lt;/a&gt; is the official public skills registry, hosting over 13,700 community-built skills. Use the CLI to search and install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Search by keyword&lt;/span&gt;
clawhub search &lt;span class="s2"&gt;"github pull request"&lt;/span&gt;

&lt;span class="c"&gt;# Install a skill&lt;/span&gt;
clawhub &lt;span class="nb"&gt;install &lt;/span&gt;pr-summary

&lt;span class="c"&gt;# Update all installed skills&lt;/span&gt;
clawhub update &lt;span class="nt"&gt;--all&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default &lt;code&gt;clawhub install&lt;/code&gt; places the skill in &lt;code&gt;./skills&lt;/code&gt; under your current directory. To install globally (visible to all your OpenClaw workspaces):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;clawhub &lt;span class="nb"&gt;install &lt;/span&gt;pr-summary &lt;span class="nt"&gt;--global&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The community has also published a curated list — &lt;a href="https://github.com/VoltAgent/awesome-openclaw-skills" rel="noopener noreferrer"&gt;awesome-openclaw-skills&lt;/a&gt; — which filters the registry down to 5,400+ vetted skills organised into categories. It's the faster way to browse if you don't already know what you're looking for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security note&lt;/strong&gt; : Treat third-party skills as untrusted code. Read the &lt;code&gt;SKILL.md&lt;/code&gt; before enabling a skill, especially if it requires API keys or system binaries. A skill that phones home with your environment variables is possible to write — the registry has moderation, but it's not a guarantee.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A Tour of the Skill Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Coding &amp;amp; Development&lt;/strong&gt; (1,200+ skills) — the largest category by far. Highlights include skills for searching academic papers via OpenAlex, generating hash-chained audit logs for agent actions, and full GitHub workflow automation (open PRs, review diffs, triage issues). If your team uses &lt;a href="https://www.deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;GitHub-based deployment workflows&lt;/a&gt;, skills in this category can trigger deploys directly from a conversation — push a branch, open a PR, and let your CI pipeline handle the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DevOps &amp;amp; Cloud&lt;/strong&gt; (400+ skills) — Docker container management, process health monitoring, cloud provider CLIs. The &lt;code&gt;agentic-devops&lt;/code&gt; skill handles common operations like restarting a crashed container or tailing logs from a service, triggered by a plain-language message. Teams running &lt;a href="https://www.deployhq.com/features/zero-downtime-deployments" rel="noopener noreferrer"&gt;zero downtime deployments&lt;/a&gt; can pair these skills with health-check monitoring to automatically roll back a release if the new container fails readiness probes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser &amp;amp; Automation&lt;/strong&gt; (335 skills) — web scraping, UI testing, form automation. Useful for monitoring pages that don't expose APIs or automating login-gated workflows. A common pattern is combining a browser skill with a notification skill to alert you when a competitor changes their pricing page or when your own staging environment returns errors after a deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Productivity &amp;amp; Tasks&lt;/strong&gt; (200+ skills) — Notion CRUD, calendar management, email triage. The &lt;code&gt;better-notion&lt;/code&gt; skill gives full create/read/update/delete access to Notion pages and databases from any messaging channel. Other standout skills in this category include time-tracking integrations that log hours against Jira tickets and daily digest generators that summarise activity across multiple project management tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Communication&lt;/strong&gt; (149 skills) — Slack, Discord, Teams, and email integrations. Post to channels, summarise threads, draft replies. The Slack skills are particularly mature — you can configure them to post deployment summaries, standup reminders, or incident alerts directly into the relevant channel without leaving the OpenClaw conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Git &amp;amp; GitHub&lt;/strong&gt; (170 skills) — beyond the basics, there are skills for commit message generation, changelog drafting, and coordinating multi-repo releases. The &lt;code&gt;release-coordinator&lt;/code&gt; skill is worth highlighting: it tags a release, generates a changelog from merged PRs, and posts the release notes to Slack in a single invocation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building Your Own Skill
&lt;/h2&gt;

&lt;p&gt;The quickest way to understand Skills is to build one. Here's a practical example: a &lt;strong&gt;daily standup note generator&lt;/strong&gt; that reads your recent git commits and drafts your standup update, so you never have to manually trawl through &lt;code&gt;git log&lt;/code&gt; before your morning call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Create the folder
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.openclaw/skills/standup-notes

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2 — Write the SKILL.md
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nano ~/.openclaw/skills/standup-notes/SKILL.md


&lt;span class="nt"&gt;---&lt;/span&gt;
name: standup-notes
description: Generates a standup update from git commits &lt;span class="k"&gt;in &lt;/span&gt;the last 24 hours across &lt;span class="nb"&gt;local &lt;/span&gt;repositories
user-invocable: &lt;span class="nb"&gt;true
&lt;/span&gt;metadata: &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"openclaw"&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"requires"&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"bins"&lt;/span&gt;:[&lt;span class="s2"&gt;"git"&lt;/span&gt;&lt;span class="o"&gt;]}}}&lt;/span&gt;
&lt;span class="nt"&gt;---&lt;/span&gt;

&lt;span class="c"&gt;# Standup Notes Generator&lt;/span&gt;

When the user asks &lt;span class="k"&gt;for &lt;/span&gt;standup notes, a standup summary, or &lt;span class="s2"&gt;"what did I work on yesterday"&lt;/span&gt;, follow these steps:

&lt;span class="c"&gt;## Steps&lt;/span&gt;

1. Ask the user which directory their repositories live &lt;span class="k"&gt;in&lt;/span&gt;, or use &lt;span class="sb"&gt;`&lt;/span&gt;~/repos&lt;span class="sb"&gt;`&lt;/span&gt; as the default &lt;span class="k"&gt;if &lt;/span&gt;they don&lt;span class="s1"&gt;'t specify.

2. Find all git repositories in that directory:
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
   find ~/repos -maxdepth 2 -name ".git" -type d | sed 's|/.git||'&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
1. For each repository found, get commits from the last 24 hours authored by the current git user:

2. Collect all results. Ignore repos with no recent commits.

3. Group the commits by repository name and draft a standup update in this format:

4. Keep the language concise and past-tense. Replace jargon-heavy commit messages with plain descriptions where possible.

5. Output the draft in a code block so it's easy to copy.

## Example trigger phrases

- &amp;lt;q&amp;gt;Give me my standup notes&amp;lt;/q&amp;gt;
- &amp;lt;q&amp;gt;What did I work on yesterday?&amp;lt;/q&amp;gt;
- &amp;lt;q&amp;gt;Draft my standup for today&amp;lt;/q&amp;gt;
- &amp;lt;q&amp;gt;Standup summary&amp;lt;/q&amp;gt;```



### Step 3 — Test it

Restart the OpenClaw gateway to pick up the new skill:



```shell
openclaw gateway restart

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then send your bot a message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;q&gt;Give me my standup notes&lt;/q&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;OpenClaw will run the git commands, collect the output, and return a formatted standup summary. Because the instructions are plain English, you can iterate on the output by replying — &lt;q&gt;make it shorter&lt;/q&gt; or &lt;q&gt;use first person&lt;/q&gt; — without editing the skill.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Extend it with an automatic post
&lt;/h3&gt;

&lt;p&gt;Once the notes look right, extend the skill to post them directly to your team's Slack channel. Add a section to the SKILL.md:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;## Posting to Slack (optional)&lt;/span&gt;

If the user asks to &lt;span class="s2"&gt;"post"&lt;/span&gt; or &lt;span class="s2"&gt;"send"&lt;/span&gt; the standup, use the Slack skill &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;if &lt;/span&gt;installed&lt;span class="o"&gt;)&lt;/span&gt; to post the formatted message to the &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="c"&gt;#standup` channel. If the Slack skill is not available, output the message and tell the user to copy it manually.&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the full workflow — generate, review, post — happens in one conversation turn.&lt;/p&gt;




&lt;h2&gt;
  
  
  Skill SKILL.md Reference
&lt;/h2&gt;

&lt;p&gt;Here are the frontmatter fields you'll use most often:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;Unique identifier (used by clawhub)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;One-line summary injected into the system prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user-invocable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;boolean&lt;/td&gt;
&lt;td&gt;Exposes the skill as a &lt;code&gt;/skill-name&lt;/code&gt; slash command (default: &lt;code&gt;true&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;disable-model-invocation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;boolean&lt;/td&gt;
&lt;td&gt;Excludes the skill from model prompts — use for slash-command-only tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;metadata&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSON (single line)&lt;/td&gt;
&lt;td&gt;Gating, emoji, OS requirements, installer specs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;metadata.openclaw.requires&lt;/code&gt; object gates the skill so it only loads when its dependencies are met:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-tool&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Does something with jq and an API key&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openclaw"&lt;/span&gt;&lt;span class="pi"&gt;:{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requires"&lt;/span&gt;&lt;span class="pi"&gt;:{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bins"&lt;/span&gt;&lt;span class="pi"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jq"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;],&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;env"&lt;/span&gt;&lt;span class="pi"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MY_API_KEY"&lt;/span&gt;&lt;span class="pi"&gt;]}}}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;jq&lt;/code&gt; isn't on &lt;code&gt;PATH&lt;/code&gt; or &lt;code&gt;MY_API_KEY&lt;/code&gt; isn't set, OpenClaw skips the skill entirely — it won't show up in the agent's prompt or the UI.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tips for Writing Effective Skills
&lt;/h2&gt;

&lt;p&gt;Writing a skill that works on your machine is straightforward. Writing one that works reliably across environments and for other users takes a bit more discipline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be explicit about prerequisites.&lt;/strong&gt; Always declare required binaries and environment variables in the &lt;code&gt;metadata.openclaw.requires&lt;/code&gt; block. A skill that silently fails because &lt;code&gt;jq&lt;/code&gt; isn't installed wastes debugging time. If your skill needs a specific version of a tool, mention it in the instructions — the requires block only checks for presence, not version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write instructions for the model, not for humans.&lt;/strong&gt; The LLM reads your SKILL.md, so phrase steps as direct commands rather than explanations. &lt;q&gt;Run &lt;code&gt;docker ps&lt;/code&gt; and parse the output&lt;/q&gt; is better than &lt;q&gt;You might want to check which containers are running.&lt;/q&gt; Ambiguity in instructions leads to inconsistent behaviour across different models and context windows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep descriptions short and specific.&lt;/strong&gt; The &lt;code&gt;description&lt;/code&gt; field is injected into the system prompt on every conversation. A verbose description eats tokens that could be used for reasoning. Aim for 10-15 words that clearly state what the skill does and when to use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handle failure gracefully.&lt;/strong&gt; Include instructions for what the agent should do when a command returns an error or produces unexpected output. &lt;q&gt;If the API returns a 401, tell the user their token may have expired&lt;/q&gt; prevents the agent from guessing or hallucinating a recovery path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test with a clean environment.&lt;/strong&gt; Before publishing, test your skill on a machine that doesn't have your personal dotfiles, aliases, or globally installed packages. What works in your shell might not work on a fresh Ubuntu server where the only tools available are what's declared in the skill's requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope each skill to one task.&lt;/strong&gt; A skill that tries to do everything — search, filter, format, post, and archive — is harder to maintain and more likely to confuse the model. Break complex workflows into smaller skills that compose together. The standup-notes example above delegates posting to the Slack skill rather than reimplementing Slack integration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting Common Issues
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Skill not appearing in the agent's prompt.&lt;/strong&gt; The most common cause is a missing or malformed &lt;code&gt;metadata.openclaw.requires&lt;/code&gt; block. If the skill requires a binary that isn't installed or an environment variable that isn't set, OpenClaw silently skips it. Run &lt;code&gt;openclaw skills list&lt;/code&gt; to see which skills loaded and which were skipped, along with the reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skill loads but doesn't trigger.&lt;/strong&gt; Check the &lt;code&gt;description&lt;/code&gt; field — if it's too vague, the model may not recognise when to invoke it. A description like &lt;q&gt;Useful helper tool&lt;/q&gt; gives the LLM almost nothing to work with. Rewrite it to match the kind of phrases users actually say: &lt;q&gt;Generates daily standup notes from recent git commits.&lt;/q&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permission denied errors.&lt;/strong&gt; Skills that run shell commands inherit the permissions of the OpenClaw process. If you're running OpenClaw as a non-root user (which you should be), commands like &lt;code&gt;systemctl restart nginx&lt;/code&gt; will fail. Either configure passwordless sudo for specific commands, or have the skill check permissions first and return a clear message instead of a raw error trace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependency not found on remote servers.&lt;/strong&gt; A skill that works locally may fail on your VPS because a binary (e.g., &lt;code&gt;jq&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt;) isn't installed there. Add all system-level dependencies to your server provisioning script or Dockerfile. Declaring them in the skill's &lt;code&gt;requires.bins&lt;/code&gt; block prevents the skill from loading at all, which is better than it loading and then crashing mid-execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conflicting skills.&lt;/strong&gt; If two skills have overlapping trigger patterns, the model may pick the wrong one. The easiest fix is to make the &lt;code&gt;description&lt;/code&gt; fields more distinct. If both skills live in the same precedence tier (e.g., both in &lt;code&gt;~/.openclaw/skills&lt;/code&gt;), the one loaded first alphabetically wins — rename the folder to control order if needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changes not taking effect after editing.&lt;/strong&gt; OpenClaw reads skills at gateway startup. After editing a &lt;code&gt;SKILL.md&lt;/code&gt;, you need to run &lt;code&gt;openclaw gateway restart&lt;/code&gt; for the changes to take effect. Hot-reloading is on the roadmap but not yet implemented.&lt;/p&gt;




&lt;h2&gt;
  
  
  Publishing to ClawHub
&lt;/h2&gt;

&lt;p&gt;When your skill works reliably and you want to share it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From the skills directory&lt;/span&gt;
clawhub publish ./standup-notes &lt;span class="nt"&gt;--slug&lt;/span&gt; standup-notes &lt;span class="nt"&gt;--version&lt;/span&gt; 1.0.0

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Publishing requires a GitHub account at least a week old (abuse prevention). The registry runs automated checks for obviously malicious patterns, but passes it to you to write a clear README and document what environment variables the skill uses and why.&lt;/p&gt;

&lt;p&gt;For updates, bump the version and republish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;clawhub publish ./standup-notes &lt;span class="nt"&gt;--slug&lt;/span&gt; standup-notes &lt;span class="nt"&gt;--version&lt;/span&gt; 1.1.0

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Keeping Skills in Sync Across Servers with DeployHQ
&lt;/h2&gt;

&lt;p&gt;If you're running OpenClaw on a VPS — or across multiple servers — keeping skills in sync manually is error-prone. The same Git-based approach from the VPS deployment guide applies here: store your custom skills in a repository and let an &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;automated deployment platform&lt;/a&gt; push changes to every server automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-openclaw-config/
├── config.json
└── skills/
    ├── standup-notes/
    │ └── SKILL.md
    └── deploy-digest/
        └── SKILL.md

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set up &lt;a href="https://www.deployhq.com/features/automatic-deployments" rel="noopener noreferrer"&gt;Git deployment automation&lt;/a&gt; so that every push to your main branch triggers a deployment. Add a post-deployment command using &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipelines&lt;/a&gt; to copy the skills into place and restart the gateway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rsync &lt;span class="nt"&gt;-av&lt;/span&gt; skills/ ~/.openclaw/skills/
openclaw gateway restart

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time you add or update a skill and push to your repository, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deploys it to all connected servers — no SSH sessions required. If a skill breaks something, use &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;one-click rollback&lt;/a&gt; to revert to the previous working version from the dashboard.&lt;/p&gt;

&lt;p&gt;For teams with larger budgets or multiple environments (staging, production), DeployHQ's &lt;a href="https://www.deployhq.com/pricing" rel="noopener noreferrer"&gt;pricing plans&lt;/a&gt; include multiple server targets per project — deploy to staging first, verify the skill works, then promote to production.&lt;/p&gt;




&lt;p&gt;The Skills ecosystem is what makes OpenClaw genuinely composable. Start with a few skills from ClawHub, modify them to match your workflow, and build custom ones for the tasks that are specific to your team. The bar to entry is low — if you can write a clear step-by-step process in plain English, you can write a skill.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Sign up for DeployHQ&lt;/a&gt; to automate skill deployments across your VPS fleet, or drop us a line at &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; if you have questions. Find us on Twitter at &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;@deployhq&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>openclaw</category>
      <category>vps</category>
      <category>skills</category>
    </item>
    <item>
      <title>SOC 2 Compliance for Deployment Workflows: What Auditors Look For</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Thu, 02 Apr 2026 17:38:52 +0000</pubDate>
      <link>https://dev.to/deployhq/soc-2-compliance-for-deployment-workflows-what-auditors-look-for-g1e</link>
      <guid>https://dev.to/deployhq/soc-2-compliance-for-deployment-workflows-what-auditors-look-for-g1e</guid>
      <description>&lt;p&gt;Every tool in your deployment pipeline is now under scrutiny. If your team is pursuing SOC 2 Type II certification — or any compliance framework that governs how software changes reach production — your deployment workflow is no longer &lt;q&gt;just DevOps.&lt;/q&gt; It's an audit surface.&lt;/p&gt;

&lt;p&gt;This guide breaks down what SOC 2 auditors actually look for in deployment tooling, which controls matter most, and how to configure your pipeline so it passes muster without slowing your team down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Your Deployment Pipeline Is an Audit Target
&lt;/h2&gt;

&lt;p&gt;SOC 2's Trust Service Criteria (TSC) don't mention &lt;q&gt;CI/CD&lt;/q&gt; or &lt;q&gt;deployment tools&lt;/q&gt; by name. But several controls map directly to how code moves from a developer's machine to production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CC6.1–CC6.2 (Logical Access)&lt;/strong&gt;: Who can trigger deployments? How are they authenticated?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CC6.3 (Role-Based Access)&lt;/strong&gt;: Can a junior developer deploy to production? Should they be able to?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CC7.1–CC7.2 (System Monitoring)&lt;/strong&gt;: Do you have immutable logs of every deployment — who, what, when, where?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CC8.1 (Change Management)&lt;/strong&gt;: Are production changes reviewed and approved before they go live?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CC9.2 (Vendor Risk)&lt;/strong&gt;: Does your deployment tool vendor meet SOC 2 requirements themselves?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The uncomfortable truth: &lt;strong&gt;68% of SOC 2 qualified opinions (failures) stem from access control weaknesses&lt;/strong&gt; in CC6.1–CC6.8. Your deployment tool is often the weakest link because it has direct write access to production servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Six Controls Auditors Actually Check
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Authentication: SSO and MFA
&lt;/h3&gt;

&lt;p&gt;Auditors want to see that access to deployment tooling is consolidated through your identity provider (IdP). This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single Sign-On (SSO)&lt;/strong&gt; via SAML or OIDC — so access is governed by your IdP (Okta, Azure AD, Google Workspace), not a separate username/password&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-Factor Authentication (MFA)&lt;/strong&gt; enforced at the organisation level, not optional per user&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centralised deprovisioning&lt;/strong&gt; — when someone leaves the team, their deployment access is revoked automatically through the IdP, not manually by an admin who might forget&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this matters&lt;/strong&gt; : If your deployment tool uses standalone credentials, every audit cycle requires manual evidence that offboarded employees had their access revoked. SSO makes this automatic and auditable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DeployHQ approach&lt;/strong&gt; : &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; supports team-based access control with configurable permissions per project and server group. For teams requiring SSO, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; integrates with enterprise identity providers to centralise authentication.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Role-Based Access Control (RBAC)
&lt;/h3&gt;

&lt;p&gt;Not everyone who can view a project should be able to deploy to production. Auditors look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separation of duties&lt;/strong&gt; — developers can deploy to staging; only designated leads or automated pipelines deploy to production&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granular permissions&lt;/strong&gt; — per-environment, per-server, per-action (view, deploy, configure)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documented access policies&lt;/strong&gt; — who has what access and why
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Developer] --&amp;gt;|Can deploy| B[Staging]
    A -.-&amp;gt;|Cannot deploy| C[Production]
    D[Team Lead] --&amp;gt;|Can deploy| B
    D --&amp;gt;|Can deploy| C
    E[CI Pipeline] --&amp;gt;|Auto-deploy| B
    E --&amp;gt;|Requires approval| C

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;DeployHQ approach&lt;/strong&gt; : DeployHQ's permission system lets you assign roles per project — restricting who can deploy to which server groups. You can set up configurations where staging deploys happen automatically on push while production deploys require manual trigger from authorised users.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Immutable Audit Trails
&lt;/h3&gt;

&lt;p&gt;Every deployment must produce a tamper-evident log entry containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who&lt;/strong&gt; triggered the deployment (authenticated identity, not just &lt;q&gt;admin&lt;/q&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What&lt;/strong&gt; was deployed (commit SHA, branch, changed files)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When&lt;/strong&gt; the deployment occurred (timestamp)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where&lt;/strong&gt; it was deployed to (server, environment)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether&lt;/strong&gt; it succeeded or failed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What changed&lt;/strong&gt; — a diff or file manifest of what was actually transferred&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These logs must be &lt;strong&gt;immutable&lt;/strong&gt; — no one should be able to delete or modify deployment records after the fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DeployHQ approach&lt;/strong&gt; : &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; maintains a complete deployment history for every project, including the triggering user, commit reference, target servers, transferred files, and build output. This history is accessible via the dashboard and API for audit evidence collection.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Change Management and Approval Gates
&lt;/h3&gt;

&lt;p&gt;SOC 2's CC8.1 requires that changes to production are authorised before they take effect. In practice, this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Branch protection rules&lt;/strong&gt; — production deployments only from protected branches (e.g., &lt;code&gt;main&lt;/code&gt;) that require pull request reviews&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual approval gates&lt;/strong&gt; — someone must explicitly approve production deploys, even if staging is automated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emergency change procedures&lt;/strong&gt; — a documented process for hotfixes that bypasses normal review, with after-the-fact review requirements
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Code Push] --&amp;gt; B{Branch?}
    B --&amp;gt;|feature/*| C[Run Tests]
    C --&amp;gt; D[Deploy to Staging]
    B --&amp;gt;|main| E{PR Approved?}
    E --&amp;gt;|Yes| F[Deploy to Production]
    E --&amp;gt;|No| G[Block Deployment]
    F --&amp;gt; H[Log Deployment]
    D --&amp;gt; H

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;DeployHQ approach&lt;/strong&gt; : &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; supports conditional deployments — you can configure automatic deployments for staging branches while requiring manual triggers for production. Combined with your Git provider's branch protection, this creates a documented approval chain from code review to production deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Environment Separation
&lt;/h3&gt;

&lt;p&gt;Auditors verify that development, staging, and production environments are isolated:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separate credentials&lt;/strong&gt; — production server credentials are not shared with staging&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network isolation&lt;/strong&gt; — staging cannot accidentally write to production databases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent configurations&lt;/strong&gt; — environment variables, API keys, and secrets are managed per environment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;DeployHQ approach&lt;/strong&gt; : DeployHQ's server groups allow you to define separate deployment targets with independent credentials, paths, and configurations for each environment. Each server group has its own deployment history and access controls.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Vendor Compliance (Your Tools Are Part of Your Audit)
&lt;/h3&gt;

&lt;p&gt;Under CC9.2, auditors assess the compliance posture of your third-party vendors. For deployment tools, this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the vendor have their own SOC 2 Type II report?&lt;/li&gt;
&lt;li&gt;What data does the vendor access? (Server credentials, source code, environment variables)&lt;/li&gt;
&lt;li&gt;How does the vendor store and protect sensitive data?&lt;/li&gt;
&lt;li&gt;What happens if the vendor has a security incident?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What to ask your deployment tool vendor&lt;/strong&gt; :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Do you have a current SOC 2 Type II report?&lt;/li&gt;
&lt;li&gt;How are server credentials stored? (At rest encryption? Key management?)&lt;/li&gt;
&lt;li&gt;Who at your organisation can access customer deployment configurations?&lt;/li&gt;
&lt;li&gt;What's your incident response process?&lt;/li&gt;
&lt;li&gt;Do you support data residency requirements?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Beyond SOC 2: Other Compliance Frameworks
&lt;/h2&gt;

&lt;p&gt;SOC 2 isn't the only framework that cares about your deployment pipeline. Here's how the requirements map across frameworks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;SOC 2&lt;/th&gt;
&lt;th&gt;ISO 27001&lt;/th&gt;
&lt;th&gt;HIPAA&lt;/th&gt;
&lt;th&gt;PCI DSS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSO/MFA&lt;/td&gt;
&lt;td&gt;CC6.1–CC6.2&lt;/td&gt;
&lt;td&gt;A.9.4.2&lt;/td&gt;
&lt;td&gt;§164.312(d)&lt;/td&gt;
&lt;td&gt;Req 8.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RBAC&lt;/td&gt;
&lt;td&gt;CC6.3&lt;/td&gt;
&lt;td&gt;A.9.2.3&lt;/td&gt;
&lt;td&gt;§164.312(a)(1)&lt;/td&gt;
&lt;td&gt;Req 7.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit trails&lt;/td&gt;
&lt;td&gt;CC7.1–CC7.2&lt;/td&gt;
&lt;td&gt;A.12.4.1&lt;/td&gt;
&lt;td&gt;§164.312(b)&lt;/td&gt;
&lt;td&gt;Req 10.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change management&lt;/td&gt;
&lt;td&gt;CC8.1&lt;/td&gt;
&lt;td&gt;A.14.2.2&lt;/td&gt;
&lt;td&gt;§164.312(e)(1)&lt;/td&gt;
&lt;td&gt;Req 6.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor management&lt;/td&gt;
&lt;td&gt;CC9.2&lt;/td&gt;
&lt;td&gt;A.15.1.1&lt;/td&gt;
&lt;td&gt;§164.308(b)(1)&lt;/td&gt;
&lt;td&gt;Req 12.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The good news: if your deployment pipeline meets SOC 2 requirements, you're largely covered for these other frameworks too. The controls are complementary, not conflicting.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Compliance Checklist for Your Deployment Pipeline
&lt;/h2&gt;

&lt;p&gt;Use this checklist to assess your current deployment workflow against SOC 2 requirements:&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication &amp;amp; Access
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[] SSO enabled for all deployment tool access&lt;/li&gt;
&lt;li&gt;[] MFA enforced at the organisation level (not optional)&lt;/li&gt;
&lt;li&gt;[] Automated deprovisioning when team members leave&lt;/li&gt;
&lt;li&gt;[] No shared credentials or service accounts used for interactive access&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Permissions &amp;amp; Roles
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[] Production deployment restricted to authorised roles&lt;/li&gt;
&lt;li&gt;[] Staging and production use separate permission sets&lt;/li&gt;
&lt;li&gt;[] Access reviews conducted quarterly (documented)&lt;/li&gt;
&lt;li&gt;[] Principle of least privilege applied to all roles&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Audit &amp;amp; Logging
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[] Every deployment logged with user identity, commit, timestamp, and target&lt;/li&gt;
&lt;li&gt;[] Deployment logs are immutable (cannot be deleted or modified)&lt;/li&gt;
&lt;li&gt;[] Logs retained for the audit period (typically 12 months)&lt;/li&gt;
&lt;li&gt;[] Failed deployments logged with error details&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Change Management
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[] Production deployments require prior approval (PR review or manual gate)&lt;/li&gt;
&lt;li&gt;[] Emergency change process documented and followed&lt;/li&gt;
&lt;li&gt;[] Rollback procedures documented and tested&lt;/li&gt;
&lt;li&gt;[] All deployments traceable to a specific code change (commit SHA)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Environment Isolation
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[] Separate credentials for each environment&lt;/li&gt;
&lt;li&gt;[] Production configuration not accessible from staging&lt;/li&gt;
&lt;li&gt;[] Environment-specific secrets management&lt;/li&gt;
&lt;li&gt;[] Network segmentation between environments&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Vendor Management
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[] Deployment tool vendor has current SOC 2 Type II report&lt;/li&gt;
&lt;li&gt;[] Vendor security review conducted annually&lt;/li&gt;
&lt;li&gt;[] Data processing agreement (DPA) in place&lt;/li&gt;
&lt;li&gt;[] Incident notification procedures agreed upon&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;If your team is beginning the SOC 2 journey, here's a practical order of operations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit your current state&lt;/strong&gt; — map out who has access to what, how deployments are triggered, and where the gaps are&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix authentication first&lt;/strong&gt; — SSO and MFA have the highest ROI because they address the most common audit failures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable audit logging&lt;/strong&gt; — make sure every deployment is logged with enough detail for an auditor to reconstruct the event&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implement approval gates&lt;/strong&gt; — start with production, then extend to other sensitive environments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document everything&lt;/strong&gt; — auditors care as much about documented policies as they do about technical controls&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The goal isn't to make deployments slower. It's to make them &lt;strong&gt;provably secure&lt;/strong&gt; — so that when an auditor asks &lt;q&gt;who deployed this change and why?&lt;/q&gt;, you have a clear, automatic answer.&lt;/p&gt;




&lt;p&gt;If you're evaluating how &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; fits into your compliance workflow, you can explore our &lt;a href="https://www.deployhq.com/features" rel="noopener noreferrer"&gt;features&lt;/a&gt; or &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;start a free trial&lt;/a&gt; to test the deployment controls against your audit requirements.&lt;/p&gt;

&lt;p&gt;For questions about enterprise features including SSO, contact us at &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or reach out on &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;X (@deployhq)&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devopsinfrastructure</category>
      <category>security</category>
      <category>soc</category>
      <category>audits</category>
    </item>
    <item>
      <title>SQLite vs PostgreSQL vs MySQL: Choosing the Right Database</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Thu, 02 Apr 2026 06:31:57 +0000</pubDate>
      <link>https://dev.to/deployhq/sqlite-vs-postgresql-vs-mysql-choosing-the-right-database-4i14</link>
      <guid>https://dev.to/deployhq/sqlite-vs-postgresql-vs-mysql-choosing-the-right-database-4i14</guid>
      <description>&lt;p&gt;Every application stores data somewhere, and for most web applications, that means a relational database. But which one? SQLite, PostgreSQL, and MySQL serve very different use cases despite sharing SQL as their query language.&lt;/p&gt;

&lt;p&gt;This guide compares all three with honest benchmarks, real configuration differences, and practical advice for choosing the right database for your project and &lt;a href="https://deployhq.com/features" rel="noopener noreferrer"&gt;deployment workflow&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;SQLite&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Type&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Embedded (serverless)&lt;/td&gt;
&lt;td&gt;Client-server&lt;/td&gt;
&lt;td&gt;Client-server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single file&lt;/td&gt;
&lt;td&gt;Server with data directory&lt;/td&gt;
&lt;td&gt;Server with data directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Setup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zero configuration&lt;/td&gt;
&lt;td&gt;Install + configure&lt;/td&gt;
&lt;td&gt;Install + configure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Concurrent writes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Limited (file-level locking)&lt;/td&gt;
&lt;td&gt;Excellent (MVCC)&lt;/td&gt;
&lt;td&gt;Good (row-level locking with InnoDB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Max database size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~281 TB (theoretical)&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basic (json functions)&lt;/td&gt;
&lt;td&gt;Advanced (JSONB with indexing)&lt;/td&gt;
&lt;td&gt;JSON type with functions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Full-text search&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;FTS5 extension&lt;/td&gt;
&lt;td&gt;Built-in (ts_vector)&lt;/td&gt;
&lt;td&gt;Built-in (InnoDB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Replication&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Streaming + logical&lt;/td&gt;
&lt;td&gt;Primary-replica, Group Replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Small apps, development, embedded&lt;/td&gt;
&lt;td&gt;Complex apps, data integrity&lt;/td&gt;
&lt;td&gt;Web apps, read-heavy workloads&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  SQLite: The Embedded Database
&lt;/h2&gt;

&lt;p&gt;SQLite isn't a server — it's a library that reads and writes directly to a single file on disk. There's no installation, no configuration, no separate process. Your application links against the SQLite library and talks directly to the database file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero configuration&lt;/strong&gt; : No server to install, configure, or maintain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single file&lt;/strong&gt; : The entire database is one &lt;code&gt;.sqlite&lt;/code&gt; or &lt;code&gt;.db&lt;/code&gt; file — easy to copy, backup, and move&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-contained&lt;/strong&gt; : No external dependencies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliable&lt;/strong&gt; : Used in every smartphone (iOS and Android), every web browser, and most operating systems&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast for reads&lt;/strong&gt; : No network overhead since it's in-process&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Limitations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent writes&lt;/strong&gt; : Only one writer at a time (readers can run concurrently). WAL mode improves this but doesn't match PostgreSQL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No user management&lt;/strong&gt; : No built-in authentication or access control&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limited ALTER TABLE&lt;/strong&gt; : Can't drop or rename columns in older versions (improved in 3.35.0+)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No network access&lt;/strong&gt; : The database file must be on the same machine as the application&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When to Use SQLite
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Development and prototyping&lt;/strong&gt; : Get started without setting up a database server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small-to-medium applications&lt;/strong&gt; : Blogs, internal tools, single-server apps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile and desktop apps&lt;/strong&gt; : The database ships with the application&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded systems&lt;/strong&gt; : IoT devices, configuration stores&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing&lt;/strong&gt; : In-memory SQLite databases run tests fast with no cleanup&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Configuration Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;

&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;app.db&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;
    CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        email TEXT UNIQUE NOT NULL,
        name TEXT NOT NULL,
        created_at TEXT DEFAULT CURRENT_TIMESTAMP
    )
&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  PostgreSQL: The Enterprise Database
&lt;/h2&gt;

&lt;p&gt;PostgreSQL is the most feature-rich open-source database. It emphasizes data integrity, SQL standards compliance, and extensibility. It handles complex queries, large datasets, and high-concurrency workloads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data integrity&lt;/strong&gt; : Strict type checking, ACID compliance, foreign key enforcement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced features&lt;/strong&gt; : JSONB, full-text search, window functions, CTEs, materialized views&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extensibility&lt;/strong&gt; : Custom types, functions, operators, and extensions (PostGIS, pgvector, TimescaleDB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency&lt;/strong&gt; : MVCC (Multi-Version Concurrency Control) handles many concurrent writers efficiently&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL standards&lt;/strong&gt; : Most standards-compliant open-source database&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Limitations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Setup complexity&lt;/strong&gt; : Requires installation, configuration, and ongoing maintenance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource usage&lt;/strong&gt; : Higher memory and CPU usage than SQLite or MySQL for simple workloads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared hosting&lt;/strong&gt; : Less commonly available on basic hosting plans&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learning curve&lt;/strong&gt; : Advanced features require deeper PostgreSQL knowledge&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When to Use PostgreSQL
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Complex applications&lt;/strong&gt; : Apps with complex queries, relationships, and data integrity requirements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics and reporting&lt;/strong&gt; : Window functions, CTEs, and materialized views&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geospatial data&lt;/strong&gt; : PostGIS extension is the gold standard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-heavy workloads&lt;/strong&gt; : JSONB with GIN indexes is powerful&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-concurrency writes&lt;/strong&gt; : MVCC handles many concurrent writers well&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Configuration Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- GIN index on JSONB for fast queries&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_users_metadata&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Full-text search&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="n"&gt;tsvector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_users_search&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  MySQL: The Web Database
&lt;/h2&gt;

&lt;p&gt;MySQL (and its fork MariaDB) has been the default database for web applications since the early 2000s. It's the &lt;q&gt;M&lt;/q&gt; in LAMP (Linux, Apache, MySQL, PHP) and remains the most widely deployed database in web hosting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ubiquity&lt;/strong&gt; : Available on virtually every hosting platform, including shared hosting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read performance&lt;/strong&gt; : Optimized for read-heavy web workloads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replication&lt;/strong&gt; : Mature primary-replica replication for scaling reads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ecosystem&lt;/strong&gt; : Massive community, extensive tooling, widespread ORM support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WordPress&lt;/strong&gt; : Powers 40%+ of the web through WordPress&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Limitations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Less strict by default&lt;/strong&gt; : Older versions silently truncate data or accept invalid dates — modern versions (8.0+) improved this significantly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer advanced features&lt;/strong&gt; : No native JSONB indexing (until recently), limited window function support in older versions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage engine complexity&lt;/strong&gt; : InnoDB vs MyISAM distinction can confuse beginners&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When to Use MySQL
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web applications&lt;/strong&gt; : Traditional LAMP/LEMP stack applications&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WordPress sites&lt;/strong&gt; : WordPress requires MySQL (or MariaDB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-heavy workloads&lt;/strong&gt; : MySQL excels at simple SELECT queries at scale&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared hosting&lt;/strong&gt; : When it's the only database available&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy systems&lt;/strong&gt; : Migrating away from MySQL is often more work than staying&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Configuration Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;CHARSET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;utf8mb4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  SQL Syntax Differences
&lt;/h2&gt;

&lt;p&gt;Despite all three using SQL, there are syntax differences that matter when writing queries or switching databases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;SQLite&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auto-increment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AUTOINCREMENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SERIAL&lt;/code&gt; or &lt;code&gt;GENERATED ALWAYS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AUTO_INCREMENT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Boolean type&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Integer (0/1)&lt;/td&gt;
&lt;td&gt;Native &lt;code&gt;BOOLEAN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TINYINT(1)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;String concat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;\&lt;/td&gt;
&lt;td&gt;`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Current time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CURRENT_TIMESTAMP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOW()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOW()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UPSERT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSERT OR REPLACE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ON CONFLICT DO UPDATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LIMIT with offset&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIMIT n OFFSET m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIMIT n OFFSET m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LIMIT m, n&lt;/code&gt; or &lt;code&gt;LIMIT n OFFSET m&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ORMs like Prisma, SQLAlchemy, Django ORM, and ActiveRecord abstract these differences, letting you switch databases without rewriting queries (in theory — in practice, database-specific features often creep in).&lt;/p&gt;

&lt;h2&gt;
  
  
  How Database Choice Affects Deployment
&lt;/h2&gt;

&lt;p&gt;Your database choice has direct implications for how you deploy and manage your application:&lt;/p&gt;

&lt;h3&gt;
  
  
  SQLite Deployments
&lt;/h3&gt;

&lt;p&gt;SQLite's database file lives alongside your application code. This creates a unique deployment challenge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your-app/
├── app.js
├── database.sqlite ← This is your database
├── package.json
└── ...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When deploying with &lt;a href="https://deployhq.com/features" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, you need to be careful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't overwrite the database on every deploy&lt;/strong&gt;. Exclude &lt;code&gt;*.sqlite&lt;/code&gt; and &lt;code&gt;*.db&lt;/code&gt; from your deployment or use DeployHQ's excluded files feature&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups are simple&lt;/strong&gt; : Just copy the file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No connection string needed&lt;/strong&gt; : The file path is the connection&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  PostgreSQL and MySQL Deployments
&lt;/h3&gt;

&lt;p&gt;These databases run as separate servers. Your application connects via a connection string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# PostgreSQL
&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;=&lt;span class="n"&gt;postgres&lt;/span&gt;://&lt;span class="n"&gt;user&lt;/span&gt;:&lt;span class="n"&gt;password&lt;/span&gt;@&lt;span class="n"&gt;db&lt;/span&gt;-&lt;span class="n"&gt;server&lt;/span&gt;:&lt;span class="m"&gt;5432&lt;/span&gt;/&lt;span class="n"&gt;myapp&lt;/span&gt;

&lt;span class="c"&gt;# MySQL
&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;=&lt;span class="n"&gt;mysql&lt;/span&gt;://&lt;span class="n"&gt;user&lt;/span&gt;:&lt;span class="n"&gt;password&lt;/span&gt;@&lt;span class="n"&gt;db&lt;/span&gt;-&lt;span class="n"&gt;server&lt;/span&gt;:&lt;span class="m"&gt;3306&lt;/span&gt;/&lt;span class="n"&gt;myapp&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store these connection strings as environment variables — never hardcode them. DeployHQ's &lt;a href="https://deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipelines&lt;/a&gt; can inject these securely during deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrations
&lt;/h3&gt;

&lt;p&gt;All three databases need schema migrations as your application evolves. Run migrations as part of your deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# In your DeployHQ build command&lt;/span&gt;
npm ci
npm run build
npm run db:migrate

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whether you push from &lt;a href="https://deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or &lt;a href="https://deployhq.com/deploy-from-gitlab" rel="noopener noreferrer"&gt;GitLab&lt;/a&gt;, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; runs your build commands (including migrations) before transferring files to the server.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://deployhq.com/for-agencies" rel="noopener noreferrer"&gt;agencies&lt;/a&gt; managing client databases, PostgreSQL and MySQL offer user-level access control — you can give each client's app its own database user with restricted permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I start with SQLite and migrate to PostgreSQL later?&lt;/strong&gt; Yes, if you use an ORM. The ORM abstracts SQL differences, making the switch mostly painless. Test thoroughly — there are always edge cases with date handling, string comparison, and JSON support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which database is fastest?&lt;/strong&gt; It depends on the workload. SQLite is fastest for single-user read-heavy applications (no network overhead). PostgreSQL handles concurrent writes best. MySQL is optimized for simple read queries at scale. Benchmark your actual workload rather than relying on generic benchmarks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need PostgreSQL for a simple blog?&lt;/strong&gt; No. SQLite or MySQL are fine for simple applications. Choose PostgreSQL when you need advanced features like JSONB, full-text search, or complex analytical queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is MariaDB the same as MySQL?&lt;/strong&gt; MariaDB forked from MySQL in 2009 and is largely compatible. Most MySQL applications work with MariaDB without changes. MariaDB has diverged more in recent versions, adding unique features, but the core SQL and wire protocol remain compatible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which database does &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; recommend?&lt;/strong&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; works with any database — it deploys your application code, and your application connects to whatever database you've set up. For new projects, PostgreSQL is the most versatile choice.&lt;/p&gt;




&lt;p&gt;There's no universally &lt;q&gt;best&lt;/q&gt; database — only the right one for your project. SQLite for simplicity, PostgreSQL for power, MySQL for ubiquity. Pick the one that matches your needs and deploy it confidently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://deployhq.com/signup" rel="noopener noreferrer"&gt;Try&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; free&lt;/strong&gt; — deploy your application with any database and manage connection strings securely. See &lt;a href="https://deployhq.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for team plans.&lt;/p&gt;




&lt;p&gt;Questions? Reach out at &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;@deployhq&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devopsinfrastructure</category>
      <category>sqlite</category>
      <category>psql</category>
      <category>mysql</category>
    </item>
    <item>
      <title>Nginx vs Apache vs Caddy: Choosing the Right Web Server</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Wed, 01 Apr 2026 13:45:21 +0000</pubDate>
      <link>https://dev.to/deployhq/nginx-vs-apache-vs-caddy-choosing-the-right-web-server-19en</link>
      <guid>https://dev.to/deployhq/nginx-vs-apache-vs-caddy-choosing-the-right-web-server-19en</guid>
      <description>&lt;p&gt;Every web application needs a web server to handle HTTP requests. Whether you're serving a static site, reverse-proxying to a Node.js backend, or hosting a &lt;a href="https://deployhq.com/blog/deploy-wordpress-with-deployhq" rel="noopener noreferrer"&gt;WordPress&lt;/a&gt; installation, your choice of web server affects performance, security, and how much time you spend on configuration.&lt;/p&gt;

&lt;p&gt;This guide compares the three most popular options — Nginx, Apache, and Caddy — with real configuration examples and practical advice for choosing the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Nginx&lt;/th&gt;
&lt;th&gt;Apache&lt;/th&gt;
&lt;th&gt;Caddy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Architecture&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Event-driven, async&lt;/td&gt;
&lt;td&gt;Process/thread per connection&lt;/td&gt;
&lt;td&gt;Event-driven, Go goroutines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Performance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Excellent for static files and high concurrency&lt;/td&gt;
&lt;td&gt;Good, but higher memory under load&lt;/td&gt;
&lt;td&gt;Very good, slightly behind Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Configuration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nginx.conf&lt;/code&gt; (custom syntax)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;httpd.conf&lt;/code&gt; + &lt;code&gt;.htaccess&lt;/code&gt; (XML-like)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Caddyfile&lt;/code&gt; (minimal, human-readable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTPS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual (Let's Encrypt + certbot)&lt;/td&gt;
&lt;td&gt;Manual (Let's Encrypt + certbot)&lt;/td&gt;
&lt;td&gt;Automatic (built-in ACME)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Per-directory config&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No (centralized only)&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;.htaccess&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No (centralized only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Module system&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Compiled modules&lt;/td&gt;
&lt;td&gt;Dynamic modules (loaded at runtime)&lt;/td&gt;
&lt;td&gt;Plugins (Go modules)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Market share&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~34% (most popular)&lt;/td&gt;
&lt;td&gt;~29% (declining)&lt;/td&gt;
&lt;td&gt;~1% (growing fast)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High-traffic sites, reverse proxy&lt;/td&gt;
&lt;td&gt;Shared hosting, PHP apps&lt;/td&gt;
&lt;td&gt;Small-to-medium sites, automatic HTTPS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Nginx
&lt;/h2&gt;

&lt;p&gt;Nginx (pronounced &lt;q&gt;engine-x&lt;/q&gt;) was created in 2004 to solve the C10K problem — handling 10,000 concurrent connections. Its event-driven architecture uses a small number of worker processes, each handling thousands of connections asynchronously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt; : Serves static files extremely fast with minimal memory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse proxy&lt;/strong&gt; : The de facto standard for proxying to application servers (Node.js, Python, Ruby, Go)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load balancing&lt;/strong&gt; : Built-in upstream balancing with multiple algorithms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low memory footprint&lt;/strong&gt; : Handles thousands of connections with minimal RAM&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Configuration Example
&lt;/h3&gt;

&lt;p&gt;Serving a static site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Cache static assets&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.(css|js|png|jpg|gif|ico)&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;30d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;immutable"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reverse proxy to a Node.js app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  When to Use Nginx
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;High-traffic websites needing maximum performance&lt;/li&gt;
&lt;li&gt;Reverse proxying to application servers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://deployhq.com/blog/how-to-deploy-to-a-vps-with-deployhq" rel="noopener noreferrer"&gt;VPS deployments&lt;/a&gt; where you control the server&lt;/li&gt;
&lt;li&gt;Load balancing across multiple backends&lt;/li&gt;
&lt;li&gt;Serving static assets alongside dynamic applications&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Apache
&lt;/h2&gt;

&lt;p&gt;Apache HTTP Server has been running the web since 1995. It introduced the concept of dynamically loadable modules and per-directory configuration via &lt;code&gt;.htaccess&lt;/code&gt; files — features that made shared hosting possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.htaccess&lt;/code&gt; support&lt;/strong&gt; : Per-directory configuration without server restart&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Module ecosystem&lt;/strong&gt; : Hundreds of modules (mod_php, mod_rewrite, mod_security)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared hosting&lt;/strong&gt; : Still the default on most shared hosting providers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation&lt;/strong&gt; : Decades of community knowledge and tutorials&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Configuration Example
&lt;/h3&gt;

&lt;p&gt;Serving a static site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;VirtualHost&lt;/span&gt;&lt;span class="sr"&gt; *:80&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="nc"&gt;ServerName&lt;/span&gt; example.com
    &lt;span class="nc"&gt;DocumentRoot&lt;/span&gt; /var/www/example.com

    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;Directory&lt;/span&gt;&lt;span class="sr"&gt; /var/www/example.com&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="nc"&gt;Options&lt;/span&gt; -Indexes +FollowSymLinks
        &lt;span class="nc"&gt;AllowOverride&lt;/span&gt; &lt;span class="ss"&gt;All&lt;/span&gt;
        &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; granted
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;Directory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;
    &lt;span class="c"&gt;# Cache static assets&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="sr"&gt; "\.(css|js|png|jpg|gif|ico)$"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="nc"&gt;ExpiresActive&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
        &lt;span class="nc"&gt;ExpiresDefault&lt;/span&gt; "access plus 30 days"
        &lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; Cache-Control "public, immutable"
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;VirtualHost&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reverse proxy to a Node.js app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;VirtualHost&lt;/span&gt;&lt;span class="sr"&gt; *:80&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="nc"&gt;ServerName&lt;/span&gt; api.example.com

    &lt;span class="nc"&gt;ProxyPreserveHost&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
    &lt;span class="nc"&gt;ProxyPass&lt;/span&gt; / http://127.0.0.1:3000/
    &lt;span class="nc"&gt;ProxyPassReverse&lt;/span&gt; / http://127.0.0.1:3000/

    &lt;span class="nc"&gt;RequestHeader&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; X-Forwarded-Proto "http"
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;VirtualHost&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  When to Use Apache
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Shared hosting environments where Nginx isn't available&lt;/li&gt;
&lt;li&gt;PHP applications that benefit from mod_php&lt;/li&gt;
&lt;li&gt;Projects that need &lt;code&gt;.htaccess&lt;/code&gt; for per-directory configuration&lt;/li&gt;
&lt;li&gt;Legacy applications with existing Apache configurations&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Caddy
&lt;/h2&gt;

&lt;p&gt;Caddy (v2, released 2020) is the newest of the three. Its headline feature is automatic HTTPS — it obtains and renews TLS certificates from Let's Encrypt without any configuration. The Caddyfile syntax is designed to be as simple as possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automatic HTTPS&lt;/strong&gt; : Certificates obtained and renewed automatically via ACME&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple configuration&lt;/strong&gt; : A basic site takes 2-3 lines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modern defaults&lt;/strong&gt; : HTTP/2, HTTP/3, TLS 1.3 out of the box&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single binary&lt;/strong&gt; : No dependencies, easy to install and update&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Configuration Example
&lt;/h3&gt;

&lt;p&gt;Serving a static site (with automatic HTTPS):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;www&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;com&lt;/span&gt;
    &lt;span class="nx"&gt;file_server&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Caddy automatically obtains a TLS certificate, redirects HTTP to HTTPS, and serves the files.&lt;/p&gt;

&lt;p&gt;Reverse proxy to a Node.js app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;reverse_proxy&lt;/span&gt; &lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  When to Use Caddy
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Small-to-medium sites where simplicity matters&lt;/li&gt;
&lt;li&gt;Projects where automatic HTTPS saves significant ops time&lt;/li&gt;
&lt;li&gt;Development environments needing local HTTPS&lt;/li&gt;
&lt;li&gt;APIs and microservices with straightforward routing&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Performance Comparison
&lt;/h2&gt;

&lt;p&gt;For static file serving and high concurrency, Nginx leads. Apache's process-per-connection model uses more memory under load. Caddy performs well but sits between the two.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Nginx&lt;/th&gt;
&lt;th&gt;Apache&lt;/th&gt;
&lt;th&gt;Caddy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Static file throughput&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Highest&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory per 10K connections&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~2.5 MB&lt;/td&gt;
&lt;td&gt;~10+ MB (prefork)&lt;/td&gt;
&lt;td&gt;~5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Concurrent connection handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good (with worker MPM)&lt;/td&gt;
&lt;td&gt;Very good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TLS handshake overhead&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low (with tuning)&lt;/td&gt;
&lt;td&gt;Low (with tuning)&lt;/td&gt;
&lt;td&gt;Low (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In practice, the web server is rarely the bottleneck. Your application code, database queries, and network latency have far more impact on user-perceived performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Web Server Choice Affects Deployment
&lt;/h2&gt;

&lt;p&gt;When you deploy with &lt;a href="https://deployhq.com/features" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, your code is transferred to the server — the web server configuration ships alongside it. Understanding the relationship between your application code and web server is important:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Configuration files deploy with your code&lt;/strong&gt;. Your &lt;code&gt;nginx.conf&lt;/code&gt;, &lt;code&gt;.htaccess&lt;/code&gt;, or &lt;code&gt;Caddyfile&lt;/code&gt; lives in your Git repository and deploys automatically when you push from &lt;a href="https://deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or &lt;a href="https://deployhq.com/deploy-from-gitlab" rel="noopener noreferrer"&gt;GitLab&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server restart may be needed&lt;/strong&gt;. After deploying Nginx or Apache config changes, you often need a reload command. DeployHQ's &lt;a href="https://deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipelines&lt;/a&gt; can run post-deploy commands to handle this&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The web server is already running&lt;/strong&gt;. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deploys your application code. The web server itself is installed and managed separately on your server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams managing multiple client sites, having a consistent web server across projects simplifies operations. &lt;a href="https://deployhq.com/for-agencies" rel="noopener noreferrer"&gt;Agencies&lt;/a&gt; often standardize on Nginx for its performance and flexibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I run Nginx and Apache together?&lt;/strong&gt; Yes. A common pattern is Nginx as a reverse proxy in front of Apache. Nginx handles static files and SSL termination; Apache handles PHP via mod_php. This gives you the best of both worlds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Caddy production-ready?&lt;/strong&gt; Yes. Caddy v2 is stable and used in production by many companies. Its automatic certificate management actually reduces ops risk compared to manual cert renewal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which is best for WordPress?&lt;/strong&gt; Apache is the traditional choice because WordPress relies on &lt;code&gt;.htaccess&lt;/code&gt; for URL rewriting. However, Nginx works well with WordPress using a &lt;code&gt;try_files&lt;/code&gt; directive, and many high-traffic WordPress sites run on Nginx.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a web server if I'm using Node.js/Express?&lt;/strong&gt; Node.js can serve HTTP directly, but a reverse proxy (typically Nginx or Caddy) in front of it provides SSL termination, static file serving, load balancing, and protection against slow clients. For production, always use a reverse proxy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which web server is easiest to learn?&lt;/strong&gt; Caddy, by a wide margin. A functional Caddyfile can be 3 lines. Nginx has a moderate learning curve. Apache's configuration is the most verbose but extremely well-documented.&lt;/p&gt;




&lt;p&gt;Your web server is the front door to your application. Whether you choose Nginx for performance, Apache for compatibility, or Caddy for simplicity, the important thing is that your deployment pipeline handles the configuration reliably.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://deployhq.com/signup" rel="noopener noreferrer"&gt;Try&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; free&lt;/strong&gt; — deploy your web server configuration alongside your application code on every push. See &lt;a href="https://deployhq.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for team plans.&lt;/p&gt;




&lt;p&gt;Questions? Reach out at &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;@deployhq&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devopsinfrastructure</category>
      <category>nginx</category>
      <category>apache</category>
      <category>webserver</category>
    </item>
    <item>
      <title>Understanding CORS: The Developer's Guide</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Mon, 30 Mar 2026 06:22:41 +0000</pubDate>
      <link>https://dev.to/deployhq/understanding-cors-the-developers-guide-5gha</link>
      <guid>https://dev.to/deployhq/understanding-cors-the-developers-guide-5gha</guid>
      <description>&lt;p&gt;If you've ever opened your browser console and seen a red error message about &lt;q&gt;CORS policy,&lt;/q&gt; you're not alone. CORS errors are one of the most common and most frustrating issues in web development — and they almost always surface after deployment, not during local development.&lt;/p&gt;

&lt;p&gt;This guide explains what CORS is, why browsers enforce it, and how to fix it in every major framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is CORS?
&lt;/h2&gt;

&lt;p&gt;CORS stands for Cross-Origin Resource Sharing. It's a security mechanism built into web browsers that controls which websites can make requests to your server.&lt;/p&gt;

&lt;p&gt;When your frontend at &lt;code&gt;https://app.example.com&lt;/code&gt; tries to fetch data from &lt;code&gt;https://api.example.com&lt;/code&gt;, the browser checks whether the API server explicitly allows requests from &lt;code&gt;app.example.com&lt;/code&gt;. If the server doesn't say yes, the browser blocks the response.&lt;/p&gt;

&lt;p&gt;This isn't your server rejecting the request — the server processes it normally. The &lt;em&gt;browser&lt;/em&gt; blocks the response from reaching your JavaScript code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Same-Origin Policy
&lt;/h2&gt;

&lt;p&gt;CORS is built on top of the Same-Origin Policy, which defines what counts as the &lt;q&gt;same origin&lt;/q&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://app.example.com:443/page
  ↑ ↑ ↑
protocol domain port

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two URLs have the same origin only if all three parts match:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;URL A&lt;/th&gt;
&lt;th&gt;URL B&lt;/th&gt;
&lt;th&gt;Same origin?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://app.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://app.example.com/api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Same protocol, domain, port&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://app.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://app.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Different protocol&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://app.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://api.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Different subdomain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://app.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://app.example.com:8080&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Different port&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When origins don't match, the browser requires CORS headers from the server before it will allow the response through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simple Requests vs Preflight Requests
&lt;/h2&gt;

&lt;p&gt;Not all cross-origin requests are handled the same way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Simple Requests
&lt;/h3&gt;

&lt;p&gt;The browser sends the request directly if it meets all of these conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Method is &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt;, or &lt;code&gt;POST&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Only standard headers (&lt;code&gt;Accept&lt;/code&gt;, &lt;code&gt;Content-Type&lt;/code&gt;, &lt;code&gt;Content-Language&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Content-Type&lt;/code&gt; is &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;, &lt;code&gt;multipart/form-data&lt;/code&gt;, or &lt;code&gt;text/plain&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For simple requests, the browser sends the request and checks the CORS headers in the response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Preflight Requests
&lt;/h3&gt;

&lt;p&gt;For anything else — &lt;code&gt;PUT&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt;, custom headers, &lt;code&gt;Content-Type: application/json&lt;/code&gt; — the browser sends a preflight &lt;code&gt;OPTIONS&lt;/code&gt; request first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser → OPTIONS /api/users (preflight)
Server → 200 OK + CORS headers
Browser → PUT /api/users (actual request)
Server → 200 OK + data


sequenceDiagram
    Browser-&amp;gt;&amp;gt;Server: OPTIONS /api/users (preflight)
    Server--&amp;gt;&amp;gt;Browser: 200 OK + CORS headers
    Browser-&amp;gt;&amp;gt;Server: PUT /api/users (actual request)
    Server--&amp;gt;&amp;gt;Browser: 200 OK + response data

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The preflight asks: &lt;q&gt;Am I allowed to make this type of request?&lt;/q&gt; If the server responds with the right headers, the browser proceeds with the actual request.&lt;/p&gt;

&lt;h2&gt;
  
  
  CORS Headers Explained
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Access-Control-Allow-Origin
&lt;/h3&gt;

&lt;p&gt;The most important header. It tells the browser which origins can access the response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access-Control-Allow-Origin: https://app.example.com

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or allow any origin (use with caution):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Access-Control-Allow-Origin: *

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Important&lt;/strong&gt; : You can only specify one origin or &lt;code&gt;*&lt;/code&gt;. You cannot list multiple origins. To support multiple origins, your server must check the request's &lt;code&gt;Origin&lt;/code&gt; header and echo it back if it's in your allowed list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access-Control-Allow-Methods
&lt;/h3&gt;

&lt;p&gt;Which HTTP methods are permitted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Access-Control-Allow-Headers
&lt;/h3&gt;

&lt;p&gt;Which request headers are permitted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Access-Control-Allow-Credentials
&lt;/h3&gt;

&lt;p&gt;Whether the browser should send cookies and authentication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Access-Control-Allow-Credentials: true

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Critical&lt;/strong&gt; : When this is &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; cannot be &lt;code&gt;*&lt;/code&gt;. You must specify the exact origin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access-Control-Max-Age
&lt;/h3&gt;

&lt;p&gt;How long (in seconds) the browser caches the preflight response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Access-Control-Max-Age: 86400

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This avoids sending a preflight &lt;code&gt;OPTIONS&lt;/code&gt; request before every actual request. Set it to 24 hours (86400) for production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access-Control-Expose-Headers
&lt;/h3&gt;

&lt;p&gt;Which response headers JavaScript can read (beyond the basic set):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Access-Control-Expose-Headers: X-Total-Count, X-Request-Id

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to Enable CORS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Node.js / Express
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;cors&lt;/code&gt; middleware is the standard approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;npm install cors


const express = require('express');
const cors = require('cors');
const app = express();

// Allow specific origin
app.use(cors({
  origin: 'https://app.example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400
}));

// Or allow multiple origins
app.use(cors({
  origin: ['https://app.example.com', 'https://staging.example.com'],
  credentials: true
}));

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Python / Django
&lt;/h3&gt;

&lt;p&gt;Install &lt;a href="https://github.com/adamchainz/django-cors-headers" rel="noopener noreferrer"&gt;django-cors-headers&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;django-cors-headers


&lt;span class="c"&gt;# settings.py&lt;/span&gt;
INSTALLED_APPS &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'corsheaders'&lt;/span&gt;,
    &lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;span class="o"&gt;]&lt;/span&gt;

MIDDLEWARE &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'corsheaders.middleware.CorsMiddleware'&lt;/span&gt;, &lt;span class="c"&gt;# Must be before CommonMiddleware&lt;/span&gt;
    &lt;span class="s1"&gt;'django.middleware.common.CommonMiddleware'&lt;/span&gt;,
    &lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;span class="o"&gt;]&lt;/span&gt;

CORS_ALLOWED_ORIGINS &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'https://app.example.com'&lt;/span&gt;,
    &lt;span class="s1"&gt;'https://staging.example.com'&lt;/span&gt;,
&lt;span class="o"&gt;]&lt;/span&gt;

CORS_ALLOW_CREDENTIALS &lt;span class="o"&gt;=&lt;/span&gt; True

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Python / Flask
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;pip&lt;/span&gt; &lt;span class="nx"&gt;install&lt;/span&gt; &lt;span class="nx"&gt;flask&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;cors&lt;/span&gt;


&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nx"&gt;flask&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Flask&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nx"&gt;flask_cors&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;__name__&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;origins&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;supports_credentials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Ruby on Rails
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;rack-cors&lt;/code&gt; to your Gemfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gem &lt;span class="s1"&gt;'rack-cors'&lt;/span&gt;


&lt;span class="c"&gt;# config/initializers/cors.rb&lt;/span&gt;
Rails.application.config.middleware.insert_before 0, Rack::Cors &lt;span class="k"&gt;do
  &lt;/span&gt;allow &lt;span class="k"&gt;do
    &lt;/span&gt;origins &lt;span class="s1"&gt;'https://app.example.com'&lt;/span&gt;
    resource &lt;span class="s1"&gt;'*'&lt;/span&gt;,
      headers: :any,
      methods: &lt;span class="o"&gt;[&lt;/span&gt;:get, :post, :put, :patch, :delete, :options],
      credentials: &lt;span class="nb"&gt;true&lt;/span&gt;,
      max_age: 86400
  end
end

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  PHP (Manual Headers)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="n"&gt;php&lt;/span&gt;
&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;allowed_origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://app.example.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HTTP_ORIGIN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HTTP_ORIGIN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;allowed_origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Allow-Origin: $allowed_origin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Allow-Methods: GET, POST, PUT, DELETE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Allow-Headers: Content-Type, Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Allow-Credentials: true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Max-Age: 86400&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;Handle&lt;/span&gt; &lt;span class="n"&gt;preflight&lt;/span&gt;
&lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;REQUEST_METHOD&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;OPTIONS&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Nginx (Server-Level)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;server_name&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;# Handle preflight
&lt;/span&gt;        &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;request_method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;OPTIONS&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;add_header&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://app.example.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;add_header&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Allow-Methods&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;GET, POST, PUT, DELETE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;add_header&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Allow-Headers&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Content-Type, Authorization&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;add_header&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Max-Age&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;add_header&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Content-Length&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;add_header&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://app.example.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;add_header&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Access-Control-Allow-Credentials&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="n"&gt;proxy_pass&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common CORS Errors and Fixes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;q&gt;No 'Access-Control-Allow-Origin' header is present&lt;/q&gt;
&lt;/h3&gt;

&lt;p&gt;The server isn't sending CORS headers. Add the appropriate middleware or headers for your framework.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;q&gt;The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'&lt;/q&gt;
&lt;/h3&gt;

&lt;p&gt;You're using &lt;code&gt;credentials: true&lt;/code&gt; (or &lt;code&gt;withCredentials: true&lt;/code&gt; in fetch/axios) but the server is responding with &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;. You must specify the exact origin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;// Wrong
res.header&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Access-Control-Allow-Origin'&lt;/span&gt;, &lt;span class="s1"&gt;'*'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

// Correct
res.header&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Access-Control-Allow-Origin'&lt;/span&gt;, &lt;span class="s1"&gt;'https://app.example.com'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Preflight Failures (OPTIONS Returns 405 or 404)
&lt;/h3&gt;

&lt;p&gt;Your server doesn't handle &lt;code&gt;OPTIONS&lt;/code&gt; requests. Some frameworks need explicit configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="sr"&gt;//&lt;/span&gt; &lt;span class="no"&gt;Express&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cors&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt; &lt;span class="n"&gt;handles&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="n"&gt;automatically&lt;/span&gt;
&lt;span class="sr"&gt;//&lt;/span&gt; &lt;span class="no"&gt;Without&lt;/span&gt; &lt;span class="n"&gt;cors&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;add:
&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Access-Control-Allow-Origin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'https://app.example.com'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Access-Control-Allow-Methods'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'GET, POST, PUT, DELETE'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Access-Control-Allow-Headers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type, Authorization'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CORS Works Locally but Fails in Production
&lt;/h3&gt;

&lt;p&gt;This is the most common deployment issue. Locally, your frontend and API often run on &lt;code&gt;localhost&lt;/code&gt; (same origin or with a dev proxy). In production, they're on different subdomains.&lt;/p&gt;

&lt;p&gt;Check that your production server's CORS configuration includes your production frontend URL, not just &lt;code&gt;localhost:3000&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  CORS in Development vs Production
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Development
&lt;/h3&gt;

&lt;p&gt;During development, CORS issues are often avoided with a dev proxy. Most frontend tools support this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vite:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.js&lt;/span&gt;
&lt;span class="n"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s1"&gt;'/api'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'http://localhost:3000'&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proxy makes API requests appear same-origin during development, bypassing CORS entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Production
&lt;/h3&gt;

&lt;p&gt;In production, you have several options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Same origin&lt;/strong&gt; : Serve frontend and API from the same domain (no CORS needed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS headers&lt;/strong&gt; : Configure your API to accept requests from your frontend's origin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse proxy&lt;/strong&gt; : Use Nginx to serve both frontend and API from the same domain&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Option 3 is often the simplest. If your frontend and API are both deployed via &lt;a href="https://deployhq.com/features" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, an Nginx reverse proxy can route &lt;code&gt;/api/*&lt;/code&gt; to your backend and everything else to your frontend — making them the same origin.&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Relates to Deployment
&lt;/h2&gt;

&lt;p&gt;CORS issues are deployment issues. They rarely appear during development (because of dev proxies or same-origin localhost) and almost always appear when code hits staging or production.&lt;/p&gt;

&lt;p&gt;When you deploy with &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; from &lt;a href="https://deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or &lt;a href="https://deployhq.com/deploy-from-gitlab" rel="noopener noreferrer"&gt;GitLab&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your CORS configuration deploys with your code&lt;/strong&gt; — it's part of your application's middleware or server config&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment-specific origins&lt;/strong&gt; can be managed via DeployHQ's &lt;a href="https://deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipelines&lt;/a&gt; — set &lt;code&gt;CORS_ORIGIN&lt;/code&gt; as a build variable that differs between staging and production&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx config deploys alongside your app&lt;/strong&gt; — your reverse proxy rules are version-controlled and auto-deployed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For &lt;a href="https://deployhq.com/for-agencies" rel="noopener noreferrer"&gt;agencies&lt;/a&gt; deploying frontend and backend separately for clients, getting CORS right from the start saves hours of debugging post-deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never use &lt;code&gt;*&lt;/code&gt; in production&lt;/strong&gt; for APIs that handle authentication. It allows any website to make requests to your API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate origins&lt;/strong&gt; : Don't blindly echo the &lt;code&gt;Origin&lt;/code&gt; header. Maintain a whitelist of allowed origins&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't confuse CORS with authentication&lt;/strong&gt;. CORS controls which &lt;em&gt;websites&lt;/em&gt; can talk to your API. It doesn't replace API authentication (tokens, sessions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials require specific origins&lt;/strong&gt;. If you use cookies or auth headers, you must specify exact origins — &lt;code&gt;*&lt;/code&gt; is not allowed&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does CORS apply to server-to-server requests?&lt;/strong&gt; No. CORS is a browser-only mechanism. Server-to-server HTTP requests (curl, fetch from Node.js, Python requests) are not subject to CORS restrictions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I disable CORS in the browser?&lt;/strong&gt; Yes, for testing — but never in production. Chrome can be launched with &lt;code&gt;--disable-web-security&lt;/code&gt;, but this is dangerous and only for debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does CORS protect my API from abuse?&lt;/strong&gt; No. CORS is enforced by browsers, not servers. Anyone can use curl or Postman to make requests directly. Use authentication and rate limiting to protect your API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my API work in Postman but not in the browser?&lt;/strong&gt; Postman doesn't enforce CORS — it's not a browser. The same request that fails in the browser due to CORS will succeed in Postman because there's no same-origin policy to enforce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I handle CORS in my application or in Nginx?&lt;/strong&gt; Either works. Application-level CORS (Express cors middleware, Django cors-headers) is more flexible and portable. Nginx-level CORS is useful when you can't modify the application. Don't configure CORS in both places — the headers can conflict.&lt;/p&gt;




&lt;p&gt;CORS is confusing at first, but it's simple once you understand the flow: the browser asks the server for permission, the server responds with headers, and the browser enforces the result. Get your headers right, test on staging before production, and you'll never see that red console error again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://deployhq.com/signup" rel="noopener noreferrer"&gt;Try&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; free&lt;/strong&gt; — deploy your frontend and backend together with consistent CORS configuration. See &lt;a href="https://deployhq.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for team plans.&lt;/p&gt;




&lt;p&gt;Questions? Reach out at &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;@deployhq&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>tutorials</category>
      <category>cors</category>
      <category>developers</category>
    </item>
    <item>
      <title>How To Deploy from Windows Using WSL2, Docker, and DeployHQ</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Wed, 25 Mar 2026 11:51:46 +0000</pubDate>
      <link>https://dev.to/deployhq/how-to-deploy-from-windows-using-wsl2-docker-and-deployhq-419i</link>
      <guid>https://dev.to/deployhq/how-to-deploy-from-windows-using-wsl2-docker-and-deployhq-419i</guid>
      <description>&lt;p&gt;Most deployment tutorials assume you are already on Linux or macOS. But if your daily machine runs Windows, you have likely hit the gap between &lt;q&gt;it works on my laptop&lt;/q&gt; and &lt;q&gt;it works on the server.&lt;/q&gt; WSL2 closes that gap — it gives you a real Linux kernel inside Windows, so your local Docker containers behave exactly like they will in production.&lt;/p&gt;

&lt;p&gt;This guide covers the full workflow: setting up WSL2 and Docker on Windows, structuring a project for deployment, and using &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; to ship changes from your Git repository to a remote Linux server — automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    Dev["Windows + WSL2"] --&amp;gt;|git push| Git["GitHub / GitLab"]
    Git --&amp;gt;|webhook| DHQ["DeployHQ"]
    DHQ --&amp;gt;|SSH + files| Prod["Linux VPS"]
    DHQ --&amp;gt;|SSH commands| Prod

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why WSL2 for deployment workflows
&lt;/h2&gt;

&lt;p&gt;If you develop on Windows and deploy to Linux servers, you have probably run into at least one of these problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Path differences&lt;/strong&gt; : Windows uses backslashes and drive letters (&lt;code&gt;C:\Users\&lt;/code&gt;), Linux uses forward slashes (&lt;code&gt;/home/&lt;/code&gt;). Build scripts and Docker volumes break when they cross this boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shell incompatibilities&lt;/strong&gt; : Bash scripts that run perfectly on the server fail in PowerShell or CMD.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File permission mismatches&lt;/strong&gt; : Linux file permissions (chmod, chown) do not exist on NTFS, causing issues with Docker volume mounts and deployment scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line ending conflicts&lt;/strong&gt; : Windows uses &lt;code&gt;\r\n&lt;/code&gt;, Linux uses &lt;code&gt;\n&lt;/code&gt;. A single wrong line ending can break a shell script or a Dockerfile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WSL2 solves all of these by running a real Linux distribution (not an emulation layer) inside a lightweight VM managed by Windows. Docker Desktop integrates with WSL2 directly, so containers run on a real Linux kernel — identical to your production server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Install WSL2 and Ubuntu
&lt;/h2&gt;

&lt;p&gt;Open PowerShell as Administrator and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wsl &lt;span class="nt"&gt;--install&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; Ubuntu-24.04

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This installs WSL2 (the default since Windows 10 version 2004) and Ubuntu 24.04 LTS. Restart your machine when prompted.&lt;/p&gt;

&lt;p&gt;After reboot, Ubuntu will launch and ask you to create a Linux username and password. These are separate from your Windows credentials.&lt;/p&gt;

&lt;p&gt;Verify the installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wsl &lt;span class="nt"&gt;--list&lt;/span&gt; &lt;span class="nt"&gt;--verbose&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see Ubuntu-24.04 running on WSL version 2.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Already have WSL1?&lt;/strong&gt; Upgrade with &lt;code&gt;wsl --set-version Ubuntu-24.04 2&lt;/code&gt;. WSL1 does not support Docker.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 2 — Install Docker Desktop with the WSL2 backend
&lt;/h2&gt;

&lt;p&gt;Download and install &lt;a href="https://www.docker.com/products/docker-desktop/" rel="noopener noreferrer"&gt;Docker Desktop for Windows&lt;/a&gt;. During setup, ensure &lt;strong&gt;Use WSL 2 based engine&lt;/strong&gt; is checked (it is the default).&lt;/p&gt;

&lt;p&gt;After installation, open Docker Desktop and go to &lt;strong&gt;Settings &amp;gt; Resources &amp;gt; WSL Integration&lt;/strong&gt;. Enable integration with your Ubuntu-24.04 distribution.&lt;/p&gt;

&lt;p&gt;Verify from your WSL2 terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;
docker compose version

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both commands should work without &lt;code&gt;sudo&lt;/code&gt;. Docker Desktop handles the daemon — you do not need to install Docker Engine inside WSL2 separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Performance tip
&lt;/h3&gt;

&lt;p&gt;Store your project files inside the WSL2 filesystem (&lt;code&gt;~/projects/&lt;/code&gt;), not on the Windows mount (&lt;code&gt;/mnt/c/&lt;/code&gt;). File operations on &lt;code&gt;/mnt/c/&lt;/code&gt; are &lt;a href="https://docs.docker.com/desktop/features/wsl/best-practices/" rel="noopener noreferrer"&gt;significantly slower&lt;/a&gt; due to the cross-filesystem translation layer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/projects
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3 — Set up Git and SSH keys
&lt;/h2&gt;

&lt;p&gt;Your WSL2 environment has its own Git configuration and SSH keys, separate from Windows. Configure them inside WSL2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.name &lt;span class="s2"&gt;"Your Name"&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.email &lt;span class="s2"&gt;"your@email.com"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate an SSH key for connecting to both your Git provider and your deployment server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"your@email.com"&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.ssh/id_ed25519.pub

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add this public key to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your &lt;a href="https://www.deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or &lt;a href="https://www.deployhq.com/deploy-from-gitlab" rel="noopener noreferrer"&gt;GitLab&lt;/a&gt; account&lt;/li&gt;
&lt;li&gt;Your remote server's &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; file&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 4 — Create a sample Docker Compose project
&lt;/h2&gt;

&lt;p&gt;Let's create a minimal project to demonstrate the full workflow. This example uses Nginx serving a static site, but the pattern works for any Docker-based application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects
&lt;span class="nb"&gt;mkdir &lt;/span&gt;my-app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;my-app
git init

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:alpine&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./public:/usr/share/nginx/html:ro&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a simple page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;public
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;h1&amp;gt;Deployed with DeployHQ from WSL2&amp;lt;/h1&amp;gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; public/index.html

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;.gitignore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;.env&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
curl http://localhost:8080

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see your HTML. Stop it with &lt;code&gt;docker compose down&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Commit and push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Initial project setup"&lt;/span&gt;
git remote add origin git@github.com:your-username/my-app.git
git push &lt;span class="nt"&gt;-u&lt;/span&gt; origin main

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5 — Set up DeployHQ
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; connects your Git repository to your server and deploys changes automatically — or on your approval. Here is how to wire it up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create a new project&lt;/strong&gt; in &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; and connect your Git repository. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; supports &lt;a href="https://www.deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, &lt;a href="https://www.deployhq.com/deploy-from-gitlab" rel="noopener noreferrer"&gt;GitLab&lt;/a&gt;, Bitbucket, and any self-hosted Git server.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add your server&lt;/strong&gt; as a deployment target. Choose SSH/SFTP and enter your server's IP, SSH user, and deployment path (e.g., &lt;code&gt;/home/deploy/my-app&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Configure &lt;a href="https://www.deployhq.com/support/ssh-commands" rel="noopener noreferrer"&gt;SSH commands&lt;/a&gt;&lt;/strong&gt; to manage Docker containers on each deployment:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Timing&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Before upload&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cd /home/deploy/my-app &amp;amp;&amp;amp; docker compose down&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stop running containers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;After upload&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cd /home/deploy/my-app &amp;amp;&amp;amp; docker compose up -d --remove-orphans&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Start updated containers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; runs SSH commands from the user's &lt;code&gt;$HOME&lt;/code&gt; by default, so the &lt;code&gt;cd&lt;/code&gt; prefix is required. See the &lt;a href="https://www.deployhq.com/support/deployments/deployment-commands-examples" rel="noopener noreferrer"&gt;deployment command examples&lt;/a&gt; for more patterns.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enable automatic deployments&lt;/strong&gt; (optional): Under project settings, enable auto-deploy so every push to &lt;code&gt;main&lt;/code&gt; triggers a deployment without manual approval.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 6 — Deploy and verify
&lt;/h2&gt;

&lt;p&gt;From your WSL2 terminal, make a change and push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;h1&amp;gt;Updated from WSL2!&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Deployed automatically.&amp;lt;/p&amp;gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; public/index.html
git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Update landing page"&lt;/span&gt;
git push

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detect the new commit via webhook&lt;/li&gt;
&lt;li&gt;Upload changed files to your server&lt;/li&gt;
&lt;li&gt;Run the SSH commands to restart Docker containers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check the deployment log in your &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ dashboard&lt;/a&gt; to confirm success.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7 — Edit with VS Code from Windows
&lt;/h2&gt;

&lt;p&gt;One of the best parts of WSL2: you can edit files inside the Linux filesystem using VS Code on Windows. Install the &lt;a href="https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl" rel="noopener noreferrer"&gt;WSL extension&lt;/a&gt;, then from your WSL2 terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/my-app
code &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;VS Code opens on Windows but operates inside WSL2 — file access, terminal, Git, and Docker commands all run natively in Linux. Your workflow becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Edit&lt;/strong&gt; in VS Code on Windows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test&lt;/strong&gt; with &lt;code&gt;docker compose up&lt;/code&gt; in the integrated WSL2 terminal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit and push&lt;/strong&gt; from the same terminal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeployHQ deploys&lt;/strong&gt; to your Linux server automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No context switching. No path translation issues. No &lt;q&gt;works on my machine&lt;/q&gt; surprises.&lt;/p&gt;




&lt;h2&gt;
  
  
  Going further: real-world project patterns
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Environment-specific configuration
&lt;/h3&gt;

&lt;p&gt;Use separate Compose files for local development and production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Local development (with hot reload, debug ports)&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.dev.yml up

&lt;span class="c"&gt;# Production uses the base file only&lt;/span&gt;
&lt;span class="c"&gt;# DeployHQ deploys docker-compose.yml to the server&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Using DeployHQ build pipelines
&lt;/h3&gt;

&lt;p&gt;If your project needs a build step (compiling assets, running tests), &lt;a href="https://www.deployhq.com/blog/build-pipelines-in-deployhq-streamline-your-deployment-workflow" rel="noopener noreferrer"&gt;DeployHQ build pipelines&lt;/a&gt; can run commands before deploying. For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs on DeployHQ's build servers, so your production server only receives the compiled output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker image builds with DeployHQ
&lt;/h3&gt;

&lt;p&gt;For projects that build custom Docker images, DeployHQ's &lt;a href="https://changelog.deployhq.com/p/docker-support-build-and-container-image-repository" rel="noopener noreferrer"&gt;Docker build feature&lt;/a&gt; can build and push images to Docker Hub, GitHub Container Registry, or other registries — then deploy a &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt; on your server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rolling back
&lt;/h3&gt;

&lt;p&gt;If a deployment breaks something, open DeployHQ's deployment history and click &lt;strong&gt;Redeploy this version&lt;/strong&gt; on the last working deployment. The SSH commands will restart the containers with the previous configuration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Docker commands not available in WSL2&lt;/strong&gt; Open Docker Desktop &amp;gt; Settings &amp;gt; Resources &amp;gt; WSL Integration and ensure your Ubuntu distro is enabled. Then restart your WSL2 terminal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slow file access in Docker volumes&lt;/strong&gt; You are likely mounting from &lt;code&gt;/mnt/c/&lt;/code&gt;. Move your project to &lt;code&gt;~/projects/&lt;/code&gt; inside WSL2. The performance difference is &lt;a href="https://docs.docker.com/desktop/features/wsl/best-practices/" rel="noopener noreferrer"&gt;substantial&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSH key not working with &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; or your server&lt;/strong&gt;Make sure you generated the key inside WSL2 (not Windows). Check permissions: &lt;code&gt;chmod 600 ~/.ssh/id_ed25519&lt;/code&gt;. Test with &lt;code&gt;ssh -T git@github.com&lt;/code&gt; or &lt;code&gt;ssh deploy@your-server-ip&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Line ending issues in Git&lt;/strong&gt; Configure Git inside WSL2 to keep Linux line endings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; core.autocrlf input

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures files stay with &lt;code&gt;\n&lt;/code&gt; endings even if you occasionally edit them from Windows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DeployHQ deployment times out&lt;/strong&gt; Docker image pulls can be slow on the first deployment. Increase the SSH command timeout in DeployHQ's server settings, or pre-pull images on the server with &lt;code&gt;docker compose pull&lt;/code&gt; before the first deploy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I use WSL2 without Docker Desktop?
&lt;/h3&gt;

&lt;p&gt;Yes. You can install Docker Engine directly inside your WSL2 Ubuntu distribution using the &lt;a href="https://docs.docker.com/engine/install/ubuntu/" rel="noopener noreferrer"&gt;official Linux install steps&lt;/a&gt;. This avoids Docker Desktop entirely and is free for all team sizes. The trade-off is that you lose the GUI, automatic updates, and the seamless cross-distro integration that Docker Desktop provides. The deployment workflow with &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; works exactly the same either way — it only cares about what you push to Git.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does DeployHQ run on Windows or inside WSL2?
&lt;/h3&gt;

&lt;p&gt;Neither. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; is a hosted service — it runs in the cloud. You interact with it through your browser or via Git webhooks. Your local environment (Windows, WSL2, macOS, Linux) only matters for writing code and pushing commits. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; connects to your Git provider and your remote server independently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is WSL2 fast enough for Docker development?
&lt;/h3&gt;

&lt;p&gt;For containers and builds, WSL2 performs at near-native Linux speed. The one bottleneck is cross-filesystem access — reading files from &lt;code&gt;/mnt/c/&lt;/code&gt; (the Windows drive) inside a container is significantly slower than reading from the native Linux filesystem (&lt;code&gt;~/&lt;/code&gt;). Keep your projects inside WSL2's home directory and performance will not be an issue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I deploy to multiple servers from the same project?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; supports &lt;a href="https://www.deployhq.com/support/servers/advanced-server-configuration" rel="noopener noreferrer"&gt;multiple servers per project&lt;/a&gt;. You can deploy the same Git repository to a staging server and a production server with different SSH commands for each. For example, staging could auto-deploy on every push, while production requires manual approval.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if my team uses a mix of Windows, macOS, and Linux?
&lt;/h3&gt;

&lt;p&gt;That is one of the main advantages of this workflow. Docker Compose files are cross-platform, Git is cross-platform, and &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; does not care what OS pushed the commit. Team members on macOS or Linux skip the WSL2 step and use Docker natively — the rest of the workflow (Git push → &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; → server) is identical.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need WSL2 if I only deploy static sites or PHP apps (no Docker)?
&lt;/h3&gt;

&lt;p&gt;No. WSL2 and Docker are optional. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; can deploy any Git repository to any server over SSH/SFTP — no containers needed. WSL2 becomes valuable when your production stack uses Docker, because it lets you run the same containers locally on Windows that you will run on the server. For static sites or traditional PHP deployments, &lt;a href="https://www.deployhq.com/support" rel="noopener noreferrer"&gt;DeployHQ works directly&lt;/a&gt; without any local Docker setup.&lt;/p&gt;




&lt;p&gt;Ready to bridge your Windows development environment with production Linux servers? &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Sign up for DeployHQ&lt;/a&gt; and connect your first project in minutes.&lt;/p&gt;

&lt;p&gt;For questions or help, reach out to &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or find us on &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;X (@deployhq)&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>windows</category>
      <category>wsl2</category>
    </item>
  </channel>
</rss>
