<?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.us-east-2.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>How to Host a Node.js Service on DeployHQ Managed VPS</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:35:49 +0000</pubDate>
      <link>https://dev.to/deployhq/how-to-host-a-nodejs-service-on-deployhq-managed-vps-51cb</link>
      <guid>https://dev.to/deployhq/how-to-host-a-nodejs-service-on-deployhq-managed-vps-51cb</guid>
      <description>&lt;p&gt;This guide walks through hosting a Node.js service (Express, Fastify, NestJS, Koa — whichever framework you're running) on a &lt;a href="https://www.deployhq.com/hosting/managed-vps" rel="noopener noreferrer"&gt;DeployHQ Managed VPS&lt;/a&gt;. End to end: provision the VPS, install Node, configure Nginx as a reverse proxy, run the service under systemd, set up the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; build pipeline, and ship the first deploy.&lt;/p&gt;

&lt;p&gt;If you're considering &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS against PaaS alternatives — Render, Railway, Fly.io — our &lt;a href="https://dev.to/deployhq/heroku-alternatives-in-2026-deployhq-your-vps-vs-render-vs-railway-vs-flyio-347b-temp-slug-7668051"&gt;Heroku alternatives roundup&lt;/a&gt; covers the trade-offs. This post is the implementation path for the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS choice. If you'd rather run your service in a container than directly on the box, &lt;a href="https://dev.to/deployhq/dockerize-a-nodejs-app-a-production-ready-walkthrough-3ik8-temp-slug-9588855"&gt;Dockerize a Node.js app&lt;/a&gt; walks the Docker path instead.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS running Ubuntu with Node.js installed&lt;/li&gt;
&lt;li&gt;Your Node service running under systemd with auto-restart&lt;/li&gt;
&lt;li&gt;Nginx reverse-proxying HTTP/HTTPS to the Node process&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; build pipeline running &lt;code&gt;npm ci&lt;/code&gt; and any compile step&lt;/li&gt;
&lt;li&gt;Custom domain over HTTPS&lt;/li&gt;
&lt;li&gt;Atomic zero-downtime deploys with one-click rollback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Expected time: 30-45 minutes for a fresh project.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A Node.js service in a Git repository (Express, Fastify, NestJS, Koa, or any HTTP framework)&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; account with beta features enabled ( &lt;strong&gt;Settings &amp;gt; Beta Features&lt;/strong&gt; )&lt;/li&gt;
&lt;li&gt;A database if your app needs one (install Postgres on the VPS or use an external managed DB)&lt;/li&gt;
&lt;li&gt;Local Node.js to verify the build before pushing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you don't have a &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; account yet, you can start a free trial — the trial includes one Managed VPS at the smallest size at no charge.&lt;/p&gt;

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

&lt;p&gt;In your &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; project, click &lt;strong&gt;New Server&lt;/strong&gt; :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enter a name (your reference only)&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Managed VPS&lt;/strong&gt; under the &lt;strong&gt;Hosting&lt;/strong&gt; section&lt;/li&gt;
&lt;li&gt;Pick a &lt;strong&gt;region&lt;/strong&gt; and &lt;strong&gt;server size&lt;/strong&gt;. For a small Express API: 1 vCPU / 1 GB RAM is workable; bump to 2 GB if you're running a database alongside.&lt;/li&gt;
&lt;li&gt;SSH keys: blank for auto-generated, or paste your own&lt;/li&gt;
&lt;li&gt;Set the &lt;strong&gt;deployment path&lt;/strong&gt; to &lt;code&gt;/var/www/node-app&lt;/code&gt; (or your preferred root)&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create Server&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Provisioning takes about a minute. The server appears in &lt;strong&gt;Settings &amp;gt; Hosted Resources&lt;/strong&gt; with IP, region, size, and sync status.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Install Node.js and supporting packages
&lt;/h2&gt;

&lt;p&gt;SSH in. Install Node via NodeSource (gives you a current LTS release with apt-managed updates):&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
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://deb.nodesource.com/setup_22.x | &lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; bash -
&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; nodejs nginx

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

&lt;/div&gt;



&lt;p&gt;Adjust the major version (&lt;code&gt;22.x&lt;/code&gt;, &lt;code&gt;20.x&lt;/code&gt;) to match what your project requires. Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--version&lt;/span&gt;
npm &lt;span class="nt"&gt;--version&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Set up the deployment directory:&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 mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/www/node-app
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt;:www-data /var/www/node-app

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Configure Nginx as a reverse proxy
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;/etc/nginx/sites-available/node-app&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;upstream&lt;/span&gt; &lt;span class="s"&gt;node_app&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.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="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&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="s"&gt;www.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://node_app&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;$http_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_redirect&lt;/span&gt; &lt;span class="no"&gt;off&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="s"&gt;60s&lt;/span&gt;&lt;span class="p"&gt;;&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;10M&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;Upgrade&lt;/code&gt; and &lt;code&gt;Connection&lt;/code&gt; headers enable WebSocket support if your Node service uses them (Socket.IO, ws, NestJS websockets).&lt;/p&gt;

&lt;p&gt;Enable the site:&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/node-app /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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Environment variables
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;/var/www/node-app/shared/.env&lt;/code&gt; with your environment variables (database URL, API keys, secrets, port):&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;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;
&lt;span class="py"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3000&lt;/span&gt;
&lt;span class="py"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;postgres://user:pass@localhost:5432/dbname&lt;/span&gt;
&lt;span class="py"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;This file lives in &lt;code&gt;shared/&lt;/code&gt; so it persists across deploys. The systemd service reads from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Create a systemd service
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;/etc/systemd/system/node-app.service&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="nt"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;Node.js application
&lt;span class="nt"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;network.target

&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="nt"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;simple
&lt;span class="nt"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;www-data
&lt;span class="nt"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/var/www/node-app/current
&lt;span class="nt"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/var/www/node-app/shared/.env
&lt;span class="nt"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/usr/bin/node dist/server.js
&lt;span class="nt"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;on-failure
&lt;span class="nt"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;5
&lt;span class="nt"&gt;StandardOutput&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;append:/var/log/node-app.log
&lt;span class="nt"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;append:/var/log/node-app.log
&lt;span class="nt"&gt;SyslogIdentifier&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;node-app

&lt;span class="k"&gt;[Install]&lt;/span&gt;
&lt;span class="nt"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;multi-user.target

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

&lt;/div&gt;



&lt;p&gt;(Adjust the &lt;code&gt;ExecStart&lt;/code&gt; path to wherever your built entry point lives — &lt;code&gt;dist/server.js&lt;/code&gt; for TypeScript projects, &lt;code&gt;src/index.js&lt;/code&gt; for plain JS, etc.)&lt;/p&gt;

&lt;p&gt;Enable but don't start yet — wait for the first deploy:&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 daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;node-app

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: Configure the build pipeline in DeployHQ
&lt;/h2&gt;

&lt;p&gt;In project build settings, add the &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipeline&lt;/a&gt; commands. These run on DeployHQ's build infrastructure before transferring to the VPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
npm run build

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

&lt;/div&gt;



&lt;p&gt;(Skip &lt;code&gt;npm run build&lt;/code&gt; if your project is plain JavaScript with no compile step. For TypeScript projects, &lt;code&gt;npm run build&lt;/code&gt; typically runs &lt;code&gt;tsc&lt;/code&gt; or your bundler.)&lt;/p&gt;

&lt;p&gt;For deeper Node build configuration — dependency caching, pnpm and Bun, pinning the Node version — see &lt;a href="https://www.deployhq.com/blog/using-nodejs-and-npm-with-deployhq-build" rel="noopener noreferrer"&gt;using Node.js and npm with the&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; build pipeline.&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;environment variables&lt;/strong&gt; UI, set any vars the build step needs at build time (not runtime — runtime vars live in &lt;code&gt;shared/.env&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;deployment hooks&lt;/strong&gt; , add a &lt;em&gt;post-deploy&lt;/em&gt; hook to restart the service:&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 node-app

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

&lt;/div&gt;



&lt;p&gt;For the &lt;code&gt;sudo&lt;/code&gt; to work without a password prompt, configure passwordless sudo for &lt;code&gt;systemctl restart node-app&lt;/code&gt; via &lt;code&gt;/etc/sudoers.d/&lt;/code&gt;:&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;# /etc/sudoers.d/deployhq-node-app
&lt;/span&gt;&lt;span class="n"&gt;deployhq&lt;/span&gt; &lt;span class="n"&gt;ALL&lt;/span&gt;=(&lt;span class="n"&gt;ALL&lt;/span&gt;) &lt;span class="n"&gt;NOPASSWD&lt;/span&gt;: /&lt;span class="n"&gt;bin&lt;/span&gt;/&lt;span class="n"&gt;systemctl&lt;/span&gt; &lt;span class="n"&gt;restart&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;-&lt;span class="n"&gt;app&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;(Replace &lt;code&gt;deployhq&lt;/code&gt; with whichever user &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deploys as.)&lt;/p&gt;

&lt;p&gt;In server settings, ensure &lt;strong&gt;shared files&lt;/strong&gt; includes &lt;code&gt;.env&lt;/code&gt; and &lt;strong&gt;shared folders&lt;/strong&gt; includes any persistent directories your app uses (uploaded files, generated artifacts, etc.). Enable &lt;strong&gt;atomic transfers&lt;/strong&gt; — see &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;one-click rollback&lt;/a&gt; for what the atomic mechanism enables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: First deploy
&lt;/h2&gt;

&lt;p&gt;Push to the repository or trigger a manual deploy. DeployHQ:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clones the repo at HEAD&lt;/li&gt;
&lt;li&gt;Runs the build pipeline (&lt;code&gt;npm ci&lt;/code&gt; + your build step)&lt;/li&gt;
&lt;li&gt;Transfers the built artifact to the VPS via SSH&lt;/li&gt;
&lt;li&gt;Symlinks &lt;code&gt;current/&lt;/code&gt; to the new release&lt;/li&gt;
&lt;li&gt;Runs the post-deploy hook (&lt;code&gt;systemctl restart node-app&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Old releases kept for rollback&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Verify the service is up:&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 status node-app
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; node-app &lt;span class="nt"&gt;-n&lt;/span&gt; 50 &lt;span class="c"&gt;# check recent logs&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;curl http://localhost:3000/&lt;/code&gt; (or your service's health endpoint) to confirm the Node process responds before exposing externally.&lt;/p&gt;

&lt;p&gt;Common first-deploy failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Module not found&lt;/strong&gt; — &lt;code&gt;npm ci --omit=dev&lt;/code&gt; skips devDependencies. If your runtime code imports something from devDeps, install it as a regular dep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build artifacts missing&lt;/strong&gt; — confirm &lt;code&gt;npm run build&lt;/code&gt; actually produces the entry point at the path systemd's &lt;code&gt;ExecStart&lt;/code&gt; references.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permission denied on systemd restart&lt;/strong&gt; — the sudoers line for passwordless restart isn't set up. Either set it up, or run the restart as root via a different mechanism.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 8: Custom domain + HTTPS
&lt;/h2&gt;

&lt;p&gt;Point your domain at the server's IP via A record. Then either:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare&lt;/strong&gt; : add domain, set DNS to Proxied, HTTPS terminated at the edge. Free and zero config on the VPS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let's Encrypt directly on the VPS&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;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; certbot python3-certbot-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; example.com &lt;span class="nt"&gt;-d&lt;/span&gt; www.example.com

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

&lt;/div&gt;



&lt;p&gt;Certbot configures Nginx for HTTPS and auto-renews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Service crashes on startup and systemd restarts it forever.&lt;/strong&gt; systemd's &lt;code&gt;Restart=on-failure&lt;/code&gt; keeps restarting failing services indefinitely. If your app crashes due to a config error, you'll see this in &lt;code&gt;journalctl -u node-app&lt;/code&gt;. Fix the underlying error, then &lt;code&gt;systemctl restart node-app&lt;/code&gt; once the fix is in place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment variables don't load.&lt;/strong&gt; systemd doesn't expand shell variables. If your &lt;code&gt;.env&lt;/code&gt; file uses &lt;code&gt;${VAR}&lt;/code&gt; syntax, systemd won't expand them — use literal values. Or use a tool like &lt;code&gt;dotenv-cli&lt;/code&gt; inside &lt;code&gt;ExecStart&lt;/code&gt; to load and run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebSockets disconnect after 60 seconds.&lt;/strong&gt; Nginx's default &lt;code&gt;proxy_read_timeout&lt;/code&gt; is 60s. For long-lived WebSocket connections, increase it: &lt;code&gt;proxy_read_timeout 86400s;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript compile fails on the build worker due to memory.&lt;/strong&gt; TypeScript can be memory-hungry on large codebases. Either run &lt;code&gt;tsc --incremental&lt;/code&gt; to use a build cache, or compile to &lt;code&gt;swc&lt;/code&gt; or &lt;code&gt;esbuild&lt;/code&gt; which use much less memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Process running as root because of missing User directive.&lt;/strong&gt; systemd defaults to running as root if &lt;code&gt;User=&lt;/code&gt; isn't set. Add &lt;code&gt;User=www-data&lt;/code&gt; to the unit file and make sure the deployment user owns the &lt;code&gt;current/&lt;/code&gt; and &lt;code&gt;shared/&lt;/code&gt; directories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No graceful shutdown handling.&lt;/strong&gt; A Node service should listen for &lt;code&gt;SIGTERM&lt;/code&gt; and finish in-flight requests before exiting. Without that, &lt;code&gt;systemctl restart&lt;/code&gt; kills mid-request connections. Add a SIGTERM handler in your server startup that closes the HTTP server, waits for in-flight requests, then exits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you've shipped
&lt;/h2&gt;

&lt;p&gt;You now have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Node.js service running on a &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS&lt;/li&gt;
&lt;li&gt;Nginx + systemd + the runtime on a real Ubuntu box you can SSH into&lt;/li&gt;
&lt;li&gt;Atomic zero-downtime deploys with one-click rollback via the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; dashboard&lt;/li&gt;
&lt;li&gt;HTTPS via Cloudflare or Let's Encrypt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the broader picture, the &lt;a href="https://www.deployhq.com/hosting" rel="noopener noreferrer"&gt;hosting hub&lt;/a&gt; catalogs all five &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; hosting types. The &lt;a href="https://dev.to/deployhq/managed-vps-hosting-on-deployhq-linux-servers-with-built-in-deployments-3d7o-temp-slug-8068925"&gt;Managed VPS pillar guide&lt;/a&gt; covers the provisioning surface and includes comparisons against DigitalOcean App Platform, Linode, and Render. For the category-wide reference, &lt;a href="https://dev.to/deployhq/best-software-deployment-tools-in-2026-3g9o"&gt;best software deployment tools in 2026&lt;/a&gt; puts &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; alongside the rest, and &lt;a href="https://www.deployhq.com/blog/deployhq-your-universal-deployment-platform-for-all-hosting-types" rel="noopener noreferrer"&gt;your universal deployment and hosting platform&lt;/a&gt; is the cross-cluster bridge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;If you also have a static frontend — a React/Vue SPA, a Next.js static export, a documentation site — pair this Managed VPS Node backend with &lt;a href="https://www.deployhq.com/hosting/static" rel="noopener noreferrer"&gt;DeployHQ Static Hosting&lt;/a&gt; in the same &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; project. The frontend rides on Cloudflare's edge, the API runs on the VPS, one project ships both with one billing relationship.&lt;/p&gt;

&lt;p&gt;For Express-specific patterns (&lt;a href="https://www.deployhq.com/guides/express" rel="noopener noreferrer"&gt;Express deployment guide&lt;/a&gt;) or Fastify, NestJS, or Koa specifics, the canonical guides cover the framework-specific build steps. For broader background on where Node deployment sits among hosting alternatives, the &lt;a href="https://www.deployhq.com/guides" rel="noopener noreferrer"&gt;DeployHQ ecosystem catalog&lt;/a&gt; is the deeper reference.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Start a free trial&lt;/a&gt; if you don't have an account yet. For the full Managed VPS product reference, see the &lt;a href="https://www.deployhq.com/support/servers/managed-vps-hosting" rel="noopener noreferrer"&gt;Managed VPS support library&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Questions or feedback on Node.js + Managed VPS? Email &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or follow &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;@deployhq&lt;/a&gt; on X for product updates.&lt;/p&gt;

</description>
      <category>devopsinfrastructure</category>
      <category>node</category>
      <category>tutorials</category>
      <category>vps</category>
    </item>
    <item>
      <title>How to Host a Laravel App on DeployHQ Managed VPS</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Thu, 30 Jul 2026 09:33:27 +0000</pubDate>
      <link>https://dev.to/deployhq/how-to-host-a-laravel-app-on-deployhq-managed-vps-3a3m</link>
      <guid>https://dev.to/deployhq/how-to-host-a-laravel-app-on-deployhq-managed-vps-3a3m</guid>
      <description>&lt;p&gt;This guide is narrowly scoped: provision a &lt;a href="https://www.deployhq.com/hosting/managed-vps" rel="noopener noreferrer"&gt;DeployHQ Managed VPS&lt;/a&gt; from inside &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; and host a Laravel application on it end to end. Server provisioning, runtime setup (PHP-FPM + Nginx), queue workers and scheduler, the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deployment pipeline, custom domain, first deploy.&lt;/p&gt;

&lt;p&gt;If you're looking for the broader Laravel deployment reference — comparisons against Forge / Deployer / Laravel Cloud, decision frameworks, the 15-point pre-deploy checklist — see our &lt;a href="https://dev.to/theqadiariesforyou/how-to-deploy-laravel-zero-downtime-build-pipelines-and-best-practices-1jcj-temp-slug-5099175"&gt;comprehensive Laravel deployment guide&lt;/a&gt;. This post is the Managed-VPS-specific path through that landscape. If your app deploys to a shared or third-party SFTP/SSH host rather than a Managed VPS, the &lt;a href="https://www.deployhq.com/blog/deploying-a-laravel-react-application-to-fortrabbit-using-deployhq" rel="noopener noreferrer"&gt;general Laravel deployment tutorial&lt;/a&gt; walks that path instead.&lt;/p&gt;

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

&lt;p&gt;By the end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS provisioned and running Ubuntu&lt;/li&gt;
&lt;li&gt;PHP-FPM, Nginx, and Composer installed and configured for Laravel&lt;/li&gt;
&lt;li&gt;Laravel queue worker running under Supervisor (Redis or database-backed queue)&lt;/li&gt;
&lt;li&gt;Laravel scheduler wired in via cron&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; build pipeline running Composer install + asset compilation&lt;/li&gt;
&lt;li&gt;Custom domain serving over HTTPS via Cloudflare or Let's Encrypt&lt;/li&gt;
&lt;li&gt;Atomic zero-downtime deploys with one-click rollback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Expected time: 30-45 minutes for a fresh project.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A Laravel project in a Git repository (GitHub, GitLab, Bitbucket)&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; account with beta features enabled ( &lt;strong&gt;Settings &amp;gt; Beta Features&lt;/strong&gt; )&lt;/li&gt;
&lt;li&gt;A database for Laravel to use — either install MySQL/PostgreSQL on the same VPS (covered below) or point at a managed database service&lt;/li&gt;
&lt;li&gt;Local Composer + PHP for running migrations and verifying the build before pushing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you don't have a &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; account yet, you can start a free trial — the trial includes one Managed VPS at the smallest size at no charge.&lt;/p&gt;

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

&lt;p&gt;In your &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; project, click &lt;strong&gt;New Server&lt;/strong&gt; :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enter a name for the server (your reference only)&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Managed VPS&lt;/strong&gt; from the protocol picker under the &lt;strong&gt;Hosting&lt;/strong&gt; section&lt;/li&gt;
&lt;li&gt;Choose a &lt;strong&gt;region&lt;/strong&gt; (data center location) and a &lt;strong&gt;server size&lt;/strong&gt;. For a small-to-medium Laravel app: 1 vCPU / 2 GB RAM is a reasonable starting point. Adjust based on traffic and queue throughput.&lt;/li&gt;
&lt;li&gt;SSH keys: leave blank to let &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; generate one automatically, or paste your own private key if you'd rather use yours&lt;/li&gt;
&lt;li&gt;Set the &lt;strong&gt;deployment path&lt;/strong&gt; to &lt;code&gt;/var/www/laravel-app&lt;/code&gt; (or your preferred root). &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; writes deploys to this path.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create Server&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Provisioning takes about a minute. Once the status flips to active, the server appears in &lt;strong&gt;Settings &amp;gt; Hosted Resources&lt;/strong&gt; with its IP address, region, size, and sync status.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Install the runtime stack
&lt;/h2&gt;

&lt;p&gt;SSH into the server using the key &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; generated. The server runs Ubuntu, so the standard apt commands work:&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 php8.3-fpm php8.3-cli php8.3-mbstring &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-xml php8.3-curl php8.3-zip php8.3-mysql php8.3-bcmath &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-intl composer redis-server supervisor mysql-server

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

&lt;/div&gt;



&lt;p&gt;(Adjust the PHP version to match what your Laravel project requires. Laravel 11 and Laravel 12 typically need PHP 8.2 or 8.3.)&lt;/p&gt;

&lt;p&gt;Enable services to start on boot:&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 &lt;span class="nb"&gt;enable &lt;/span&gt;nginx php8.3-fpm redis-server mysql supervisor
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start nginx php8.3-fpm redis-server mysql supervisor

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

&lt;/div&gt;



&lt;p&gt;Create the Laravel app directory if it doesn't exist:&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 mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/www/laravel-app
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt;:www-data /var/www/laravel-app

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Configure Nginx for Laravel
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;/etc/nginx/sites-available/laravel-app&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;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="s"&gt;www.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/laravel-app/current/public&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;X-Frame-Options&lt;/span&gt; &lt;span class="s"&gt;"SAMEORIGIN"&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;X-Content-Type-Options&lt;/span&gt; &lt;span class="s"&gt;"nosniff"&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.php&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;charset&lt;/span&gt; &lt;span class="s"&gt;utf-8&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="n"&gt;/index.php?&lt;/span&gt;&lt;span class="nv"&gt;$query_string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/favicon.ico&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;access_log&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;log_not_found&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/robots.txt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;access_log&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;log_not_found&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;error_page&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="n"&gt;/index.php&lt;/span&gt;&lt;span class="p"&gt;;&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;\.php$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/var/run/php/php8.3-fpm.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_param&lt;/span&gt; &lt;span class="s"&gt;SCRIPT_FILENAME&lt;/span&gt; &lt;span class="nv"&gt;$realpath_root$fastcgi_script_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&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;/\.(?!well-known).*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&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 &lt;code&gt;current&lt;/code&gt; symlink in the root path is important — DeployHQ's zero-downtime deployments use atomic symlink switching, and Nginx serves whatever &lt;code&gt;current/public/&lt;/code&gt; points to at any given moment.&lt;/p&gt;

&lt;p&gt;Enable the site:&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/laravel-app /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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Set up Laravel database + environment
&lt;/h2&gt;

&lt;p&gt;Create the database:&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;mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"CREATE DATABASE laravel_app CHARACTER SET utf8mb4;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"CREATE USER 'laravel_app'@'localhost' IDENTIFIED BY 'STRONG-PASSWORD';"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"GRANT ALL ON laravel_app.* TO 'laravel_app'@'localhost';"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;/var/www/laravel-app/shared/.env&lt;/code&gt; with your environment variables (DB credentials, app key, mail config, queue driver, etc.). DeployHQ's symlinks pattern preserves this file across deploys — the &lt;code&gt;shared/&lt;/code&gt; directory persists, while each release gets a new directory under &lt;code&gt;releases/&lt;/code&gt; that's symlinked from &lt;code&gt;current&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Configure the build pipeline in DeployHQ
&lt;/h2&gt;

&lt;p&gt;In project build settings, add the build commands. DeployHQ's &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipeline&lt;/a&gt; runs these on DeployHQ's build infrastructure before uploading to the VPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt; &lt;span class="nt"&gt;--no-interaction&lt;/span&gt;
npm ci
npm run build

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

&lt;/div&gt;



&lt;p&gt;(Skip the npm steps if your Laravel project has no frontend build pipeline.)&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;deployment hooks&lt;/strong&gt; section, add a &lt;em&gt;post-deploy&lt;/em&gt; hook to run on the VPS itself:&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/laravel-app/current
php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;queue:restart&lt;/code&gt; signals all running queue workers to gracefully exit and restart with the new code — important so workers don't run stale code after a deploy.&lt;/p&gt;

&lt;p&gt;In the server settings under &lt;strong&gt;Configuration&lt;/strong&gt; , ensure &lt;strong&gt;shared files&lt;/strong&gt; includes &lt;code&gt;.env&lt;/code&gt; and &lt;strong&gt;shared folders&lt;/strong&gt; includes &lt;code&gt;storage&lt;/code&gt; (Laravel writes to &lt;code&gt;storage/logs&lt;/code&gt;, &lt;code&gt;storage/framework/cache&lt;/code&gt;, and &lt;code&gt;storage/framework/sessions&lt;/code&gt; — these need to persist across deploys). Enable &lt;strong&gt;atomic transfers&lt;/strong&gt; and &lt;strong&gt;zero-downtime mode&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: First deploy
&lt;/h2&gt;

&lt;p&gt;Push to your repository (or trigger a manual deploy). DeployHQ:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clones the repo at HEAD&lt;/li&gt;
&lt;li&gt;Runs the build pipeline on DeployHQ's build infrastructure&lt;/li&gt;
&lt;li&gt;Transfers the built artifact to the VPS via SSH&lt;/li&gt;
&lt;li&gt;Symlinks &lt;code&gt;current/&lt;/code&gt; to the new release&lt;/li&gt;
&lt;li&gt;Runs the post-deploy hooks (migrations, cache, queue restart)&lt;/li&gt;
&lt;li&gt;Old releases are kept for rollback&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When the deploy completes, visit your server's IP (or domain) and verify Laravel responds.&lt;/p&gt;

&lt;p&gt;Common first-deploy failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permission errors on storage/&lt;/strong&gt; — &lt;code&gt;chown -R www-data:www-data /var/www/laravel-app/shared/storage&lt;/code&gt; and verify the symlinks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;APP_KEY missing in .env&lt;/strong&gt; — Laravel needs a base64 app key; generate locally with &lt;code&gt;php artisan key:generate --show&lt;/code&gt; and put the result in your VPS &lt;code&gt;.env&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrations fail&lt;/strong&gt; — run &lt;code&gt;php artisan migrate --force&lt;/code&gt; manually first via SSH and confirm the database is reachable&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 7: Queue workers under Supervisor
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;/etc/supervisor/conf.d/laravel-queue.conf&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;[program:laravel-queue]&lt;/span&gt;
&lt;span class="py"&gt;process_name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;%(program_name)s_%(process_num)02d&lt;/span&gt;
&lt;span class="py"&gt;command&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;php /var/www/laravel-app/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600&lt;/span&gt;
&lt;span class="py"&gt;autostart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;stopasgroup&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;killasgroup&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&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;www-data&lt;/span&gt;
&lt;span class="py"&gt;numprocs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="py"&gt;redirect_stderr&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;stdout_logfile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/log/laravel-queue.log&lt;/span&gt;
&lt;span class="py"&gt;stopwaitsecs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3600&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Reload Supervisor:&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;supervisorctl reread
&lt;span class="nb"&gt;sudo &lt;/span&gt;supervisorctl update
&lt;span class="nb"&gt;sudo &lt;/span&gt;supervisorctl start laravel-queue:&lt;span class="k"&gt;*&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;numprocs=2&lt;/code&gt; runs two workers; increase if your job throughput needs more. Each worker handles jobs until &lt;code&gt;max-time&lt;/code&gt; (1 hour) and gracefully restarts.&lt;/p&gt;

&lt;p&gt;Schedule Laravel's task scheduler via cron:&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;crontab &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data
&lt;span class="c"&gt;# Add this line:&lt;/span&gt;
&lt;span class="c"&gt;# * * * * * cd /var/www/laravel-app/current &amp;amp;&amp;amp; php artisan schedule:run &amp;gt;&amp;gt; /dev/null 2&amp;gt;&amp;amp;1&lt;/span&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 8: Custom domain + HTTPS
&lt;/h2&gt;

&lt;p&gt;Point your domain at the server's IP via A record. Then either:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Cloudflare&lt;/strong&gt; (free): add the domain to Cloudflare, set DNS to &lt;q&gt;Proxied&lt;/q&gt;, and Cloudflare handles HTTPS termination at its edge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Let's Encrypt directly on the VPS&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;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; certbot python3-certbot-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; example.com &lt;span class="nt"&gt;-d&lt;/span&gt; www.example.com

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

&lt;/div&gt;



&lt;p&gt;Certbot updates the Nginx config to listen on 443, configures SSL, and sets up auto-renewal via systemd timer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Queue workers don't pick up new code after deploy.&lt;/strong&gt; Long-running workers keep the old code in memory. The &lt;code&gt;php artisan queue:restart&lt;/code&gt; in the post-deploy hook signals workers to exit gracefully and restart with the new code on the next iteration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;storage/&lt;/code&gt; symlinks break across deploys.&lt;/strong&gt; The &lt;code&gt;php artisan storage:link&lt;/code&gt; command needs to point &lt;code&gt;public/storage&lt;/code&gt; at &lt;code&gt;storage/app/public&lt;/code&gt;. With atomic deploys, this needs to be set up in &lt;code&gt;shared/&lt;/code&gt; (not in the release). Standard pattern: create the symlink in &lt;code&gt;shared/storage&lt;/code&gt;, and let the deploy pipeline create the per-release &lt;code&gt;public/storage&lt;/code&gt; symlink pointing to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sessions die on every deploy.&lt;/strong&gt; If &lt;code&gt;SESSION_DRIVER=file&lt;/code&gt; and the session files live in &lt;code&gt;storage/framework/sessions&lt;/code&gt;, make sure that directory is in the &lt;strong&gt;shared folders&lt;/strong&gt; list — otherwise it's recreated empty on each deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cron doesn't fire because of permissions.&lt;/strong&gt; Laravel's scheduler runs as the cron user. Make sure the cron is registered for the right user (usually &lt;code&gt;www-data&lt;/code&gt;) and that the user owns the relevant directories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Composer runs out of memory during build.&lt;/strong&gt; Composer can be memory-hungry on complex dependency trees. Either bump the build worker's memory or use &lt;code&gt;COMPOSER_MEMORY_LIMIT=-1&lt;/code&gt; in the build pipeline's environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you've shipped
&lt;/h2&gt;

&lt;p&gt;You now have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Laravel app running on a &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS&lt;/li&gt;
&lt;li&gt;PHP-FPM + Nginx + MySQL + Redis on a real Ubuntu box you can SSH into&lt;/li&gt;
&lt;li&gt;Queue workers under Supervisor and the scheduler under cron&lt;/li&gt;
&lt;li&gt;Atomic zero-downtime deploys with one-click rollback via the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; dashboard&lt;/li&gt;
&lt;li&gt;HTTPS via Cloudflare or Let's Encrypt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the broader picture of where Managed VPS fits, the &lt;a href="https://www.deployhq.com/hosting" rel="noopener noreferrer"&gt;hosting hub&lt;/a&gt; catalogs all five &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; hosting types. The &lt;a href="https://dev.to/deployhq/managed-vps-hosting-on-deployhq-linux-servers-with-built-in-deployments-3d7o-temp-slug-8068925"&gt;Managed VPS pillar guide&lt;/a&gt; covers the provisioning surface in more depth and includes the comparison against DigitalOcean App Platform, Linode, and Render.&lt;/p&gt;

&lt;p&gt;If you're choosing between &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; as your deployment tool and Laravel Forge as a provisioning + deployment tool, &lt;a href="https://dev.to/deployhq/deployhq-vs-laravel-forge-how-they-differ-and-how-to-use-them-together-3n7f"&gt;DeployHQ vs Laravel Forge&lt;/a&gt; covers the trade-offs and how to use them together. For a broader category reference, &lt;a href="https://dev.to/deployhq/best-software-deployment-tools-in-2026-3g9o"&gt;best software deployment tools in 2026&lt;/a&gt; places &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; alongside the rest of the landscape, and &lt;a href="https://www.deployhq.com/blog/deployhq-your-universal-deployment-platform-for-all-hosting-types" rel="noopener noreferrer"&gt;your universal deployment and hosting platform&lt;/a&gt; is the cross-cluster bridge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;If you also have a static frontend (an Astro marketing site, a SvelteKit pre-rendered docs site), pair this Managed VPS with &lt;a href="https://www.deployhq.com/hosting/static" rel="noopener noreferrer"&gt;DeployHQ Static Hosting&lt;/a&gt; in the same &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; project — frontend on Cloudflare's edge, Laravel API on the VPS, one project, one billing relationship.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://www.deployhq.com/guides/laravel" rel="noopener noreferrer"&gt;Laravel-specific deployment depth&lt;/a&gt; — Composer optimization, queue strategies, deployment hooks, environment management — the canonical Laravel guide is the next reference. And for the smooth migration story if you're moving from Forge or Vapor, the &lt;a href="https://dev.to/deployhq/migrating-to-deployhq-a-smooth-transition-guide-151g"&gt;migrating to&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; guide covers the broader pattern.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Start a free trial&lt;/a&gt; if you don't have an account yet. For the full Managed VPS product reference, see the &lt;a href="https://www.deployhq.com/support/servers/managed-vps-hosting" rel="noopener noreferrer"&gt;Managed VPS support library&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Questions or feedback on Laravel + Managed VPS? Email &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or follow &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;@deployhq&lt;/a&gt; on X for product updates.&lt;/p&gt;

</description>
      <category>devopsinfrastructure</category>
      <category>php</category>
      <category>tutorials</category>
      <category>vps</category>
    </item>
    <item>
      <title>Self-host BugSink on a VPS — the open-source Sentry alternative</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Mon, 27 Jul 2026 10:14:08 +0000</pubDate>
      <link>https://dev.to/deployhq/self-host-bugsink-on-a-vps-the-open-source-sentry-alternative-2k0a</link>
      <guid>https://dev.to/deployhq/self-host-bugsink-on-a-vps-the-open-source-sentry-alternative-2k0a</guid>
      <description>&lt;h2&gt;
  
  
  You don't need a Kubernetes cluster to track production errors
&lt;/h2&gt;

&lt;p&gt;Sentry is a great product. Sentry's self-hosted distribution is also a great product — and a 20-service Docker Compose stack with substantial CPU, RAM, and disk needs. If your team's main interaction with self-hosted observability is a Slack thread that begins &lt;q&gt;the Sentry box is OOMing again&lt;/q&gt;, you're not alone.&lt;/p&gt;

&lt;p&gt;BugSink takes the opposite approach. One application container, one Postgres database, Sentry-SDK-compatible. You point your existing Sentry SDKs at it by changing the DSN. Per &lt;a href="https://www.bugsink.com/" rel="noopener noreferrer"&gt;BugSink's own marketing page&lt;/a&gt;, it's &lt;q&gt;more lightweight and less complicated than Sentry&lt;/q&gt; — and the install proves it.&lt;/p&gt;

&lt;p&gt;This tutorial wires BugSink onto a $5 VPS with Docker, Postgres, HTTPS via Nginx, and Git-based auto-deploys from &lt;a href="https://www.deployhq.com/" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;. By the end, every change to the stack — env vars, container versions, Nginx config — ships from a &lt;code&gt;git push&lt;/code&gt;, with &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;one-click rollback in DeployHQ&lt;/a&gt; if a tweak breaks something.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BugSink&lt;/strong&gt; (current 2.x major version) as a single application container&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres 17&lt;/strong&gt; in another container for persistent storage of issues, events, and project config&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose&lt;/strong&gt; to glue them together with persistent volumes and restart policies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx + Let's Encrypt&lt;/strong&gt; for HTTPS termination in front of BugSink&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeployHQ&lt;/strong&gt; for Git-based auto-deploys so the whole stack lives in version control&lt;/li&gt;
&lt;li&gt;A working &lt;strong&gt;DSN&lt;/strong&gt; you can drop into any Sentry SDK to redirect events to your VPS&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why BugSink instead of Sentry self-hosted?&lt;/strong&gt; Three reasons that come up in real conversations. &lt;strong&gt;Cost predictability&lt;/strong&gt; — BugSink is free to self-host with unlimited users and events; you pay for the VPS, full stop. &lt;strong&gt;Resource footprint&lt;/strong&gt; — one app + one DB versus Sentry's ~20-service Helm/Compose distribution. &lt;strong&gt;Data residency&lt;/strong&gt; — for teams who need stack traces (and the source context Sentry sends with them) to stay on infrastructure they own, BugSink is much easier to keep audit-ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a VPS instead of a hosted plan?&lt;/strong&gt; If volume is low (handful of apps, hobby projects) the VPS is genuinely cheaper. If volume is high, you're paying once for a box you control instead of per-event metering forever. Either way, a $5/month VPS at &lt;a href="https://www.deployhq.com/blog/how-to-deploy-django-on-a-budget-with-hetzner-and-deployhq" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt;, DigitalOcean, Vultr, or Linode is enough to start.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;A VPS running Ubuntu 22.04 or 24.04 with SSH access&lt;/li&gt;
&lt;li&gt;A domain name pointing at the VPS (e.g. &lt;code&gt;bugsink.example.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A GitHub account with an empty private repo for the deploy config&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;Docker and Docker Compose installed locally for testing&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;SSH in as &lt;code&gt;root&lt;/code&gt; and install Docker:&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

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

&lt;/div&gt;



&lt;p&gt;Create a non-root &lt;code&gt;deploy&lt;/code&gt; user that &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; will SSH in as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;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/bugsink
&lt;span class="nb"&gt;chown &lt;/span&gt;deploy:deploy /home/deploy/bugsink

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

&lt;/div&gt;



&lt;p&gt;Copy your SSH key to the new user from your local machine so &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; can authenticate without a password:&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 &lt;code&gt;ssh deploy@your-vps-ip&lt;/code&gt; works passwordless. If it works for you, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; can do the same.&lt;/p&gt;

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

&lt;p&gt;BugSink ships an official production sample at &lt;a href="https://github.com/bugsink/bugsink/blob/main/docker-compose-sample.yaml" rel="noopener noreferrer"&gt;github.com/bugsink/bugsink/blob/main/docker-compose-sample.yaml&lt;/a&gt;. Use it as the starting point, then tighten the production knobs.&lt;/p&gt;

&lt;p&gt;In your fresh GitHub repo, create &lt;code&gt;compose.yaml&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;db&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:17-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;bugsinkuser&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;bugsink&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;db-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="s"&gt;pg_isready -h db&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;start_period&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;interval&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;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;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;bugsink/bugsink:2&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;db&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;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;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:8000:8000"&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;SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${SECRET_KEY}&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://bugsinkuser:${POSTGRES_PASSWORD}@db:5432/bugsink&lt;/span&gt;
      &lt;span class="na"&gt;PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
      &lt;span class="na"&gt;BEHIND_HTTPS_PROXY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;USE_X_FORWARDED_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&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;python&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-c&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'import&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;requests;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;requests.get(&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000/&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;).raise_for_status()'"&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;5s&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;20s&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;10&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1G&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0"&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;db-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Three changes from BugSink's sample worth flagging:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SECRET_KEY&lt;/code&gt; and &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; are read from environment&lt;/strong&gt; , not baked into the file. We'll inject them from &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; as a config file in the next step. Never commit secrets to the repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port 8000 is bound to &lt;code&gt;127.0.0.1&lt;/code&gt;&lt;/strong&gt; , not &lt;code&gt;0.0.0.0&lt;/code&gt;. BugSink should not be exposed directly to the public internet — Nginx will handle that. Bind to localhost; firewall the external port at the host level too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BEHIND_HTTPS_PROXY&lt;/code&gt; and &lt;code&gt;USE_X_FORWARDED_HOST&lt;/code&gt; are both &lt;code&gt;true&lt;/code&gt;&lt;/strong&gt; because Nginx will terminate TLS and forward &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; / &lt;code&gt;X-Forwarded-Host&lt;/code&gt;. BugSink needs to know to trust those headers when generating absolute URLs (otherwise the DSN it shows you will use &lt;code&gt;http://&lt;/code&gt;, and the SDK won't be able to connect).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Generate a real &lt;code&gt;SECRET_KEY&lt;/code&gt; locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import secrets; print(secrets.token_urlsafe(50))"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;And a Postgres password the same way. Save both — you'll paste them into &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; in the next step.&lt;/p&gt;

&lt;p&gt;Also add a &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 plaintext"&gt;&lt;code&gt;.env
*.local

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

&lt;/div&gt;



&lt;p&gt;Commit everything (the compose file, the gitignore, nothing else) and push to GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Wire it up with DeployHQ
&lt;/h2&gt;

&lt;p&gt;This is where the stack stops being a one-off SSH session and starts being a real deployment.&lt;/p&gt;

&lt;p&gt;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 GitHub 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 manual webhook setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a server.&lt;/strong&gt; Use the VPS IP, port 22, username &lt;code&gt;deploy&lt;/code&gt;, and the SSH key you authorised in Step 1. Set the deployment path to &lt;code&gt;/home/deploy/bugsink&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a config file&lt;/strong&gt; at &lt;code&gt;/home/deploy/bugsink/.env&lt;/code&gt; containing:&lt;code&gt;
SECRET_KEY=&amp;lt;the value you generated&amp;gt;
POSTGRES_PASSWORD=&amp;lt;the password you generated&amp;gt;
&lt;/code&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; writes this file to disk before every deploy, so Compose picks it up — and the secrets never live in your repo or your container image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure the SSH command.&lt;/strong&gt; Under the server's SSH commands, add:&lt;code&gt;bash
cd /home/deploy/bugsink &amp;amp;&amp;amp; docker compose up -d
&lt;/code&gt;That's it. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; checks out the repo, writes the env file, SSHes in, and runs Compose. New container versions, env tweaks, resource limit changes — all of them ship via the same one-line deploy.&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 build.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Trigger the first deploy manually. Watch the log: clone, write env, run Compose. About 30 seconds later, BugSink and Postgres are both up. Check with &lt;code&gt;docker compose ps&lt;/code&gt; on the VPS — both services should be &lt;code&gt;healthy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If anything breaks at this stage, DeployHQ's one-click rollback restores the previous deploy in seconds, so you can iterate on config without leaving the box in a half-broken state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: HTTPS with Nginx and Let's Encrypt
&lt;/h2&gt;

&lt;p&gt;BugSink is now running on &lt;code&gt;127.0.0.1:8000&lt;/code&gt;. To put it on the internet at &lt;code&gt;bugsink.example.com&lt;/code&gt; with HTTPS:&lt;/p&gt;

&lt;p&gt;On the VPS (still as &lt;code&gt;root&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;Create &lt;code&gt;/etc/nginx/sites-available/bugsink&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;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;bugsink.example.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;25M&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:8000&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_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-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_read_timeout&lt;/span&gt; &lt;span class="s"&gt;60s&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;Two notes. &lt;strong&gt;&lt;code&gt;client_max_body_size 25M&lt;/code&gt;&lt;/strong&gt; — SDK event payloads can be sizeable (source context, attachments) and the default 1MB will reject them. &lt;strong&gt;&lt;code&gt;X-Forwarded-Proto&lt;/code&gt; and &lt;code&gt;X-Forwarded-Host&lt;/code&gt;&lt;/strong&gt; are why we set &lt;code&gt;BEHIND_HTTPS_PROXY&lt;/code&gt; and &lt;code&gt;USE_X_FORWARDED_HOST&lt;/code&gt; in Compose; the two halves have to agree.&lt;/p&gt;

&lt;p&gt;Enable the site and get a cert:&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;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/nginx/sites-available/bugsink /etc/nginx/sites-enabled/
nginx &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; systemctl reload nginx
certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; bugsink.example.com

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;certbot&lt;/code&gt; handles cert provisioning, the redirect from HTTP to HTTPS, and auto-renewal via systemd timers. After this, &lt;code&gt;https://bugsink.example.com/&lt;/code&gt; returns the BugSink login screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: First login and creating a project
&lt;/h2&gt;

&lt;p&gt;The first time you visit your BugSink instance you'll need a superuser. The cleanest way to create one is a one-off &lt;code&gt;manage.py&lt;/code&gt; invocation against the running container:&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;web python &lt;span class="nt"&gt;-m&lt;/span&gt; bugsink.manage createsuperuser

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

&lt;/div&gt;



&lt;p&gt;You'll be prompted for email, username, and password. The official &lt;code&gt;docker-compose-sample.yaml&lt;/code&gt; also supports a &lt;code&gt;CREATE_SUPERUSER: email:password&lt;/code&gt; env var for the very first boot, but injecting a password through env is messy; the &lt;code&gt;createsuperuser&lt;/code&gt; command keeps the secret off your disk history.&lt;/p&gt;

&lt;p&gt;Log in. Create a new &lt;strong&gt;project&lt;/strong&gt; (one per application is the standard pattern — same as Sentry). BugSink shows you the DSN on the project page; it looks like a normal Sentry DSN, just pointing at your VPS hostname instead of &lt;code&gt;o&amp;lt;your-org&amp;gt;.ingest.sentry.io&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Point an application at BugSink
&lt;/h2&gt;

&lt;p&gt;The whole point of Sentry-SDK compatibility: nothing in your application code changes.&lt;/p&gt;

&lt;p&gt;In the SDK init for a Python app, that's:&lt;br&gt;
&lt;/p&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;sentry_sdk&lt;/span&gt;

&lt;span class="n"&gt;sentry_sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;dsn&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://&amp;lt;your-bugsink-key&amp;gt;@bugsink.example.com/&amp;lt;project-id&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-app@1.2.3&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same for JavaScript, Ruby, PHP, Go, .NET, Rust — every Sentry SDK that takes a DSN takes BugSink's. Per &lt;a href="https://www.bugsink.com/docs/quickstart/" rel="noopener noreferrer"&gt;BugSink's own quickstart docs&lt;/a&gt;, install an SDK, copy the DSN, trigger an error on purpose, and it appears in BugSink within seconds.&lt;/p&gt;

&lt;p&gt;Two things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;release&lt;/code&gt; is the same field Sentry uses&lt;/strong&gt; , and BugSink's release tracking works the same way — issues get tagged with the release they first appeared in. The &lt;code&gt;release&lt;/code&gt; string should be a version + commit SHA your build pipeline produces, not a timestamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;environment&lt;/code&gt;&lt;/strong&gt; has to match between your SDK config and any release notifications you send (more on that below). &lt;code&gt;prod&lt;/code&gt; vs &lt;code&gt;production&lt;/code&gt; mismatches will silently break attribution.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Production hardening
&lt;/h2&gt;

&lt;p&gt;A working deploy is the floor, not the ceiling. Five things to add before BugSink starts taking real traffic:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Pin the image by digest, not the floating &lt;code&gt;:2&lt;/code&gt; tag.&lt;/strong&gt; BugSink's &lt;code&gt;:2&lt;/code&gt; tag is the current major-version label, and the maintainer pushes patch updates to it. For reproducible builds, pin to a specific digest (&lt;code&gt;bugsink/bugsink@sha256:...&lt;/code&gt;) and bump it explicitly via a commit. Same for &lt;code&gt;postgres:17-alpine&lt;/code&gt; — pin the minor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Back up the Postgres volume.&lt;/strong&gt; Stack traces are not infinite — BugSink has retention policies — but losing your historical issue grouping mid-incident is painful. A daily &lt;code&gt;pg_dump&lt;/code&gt; to S3 (or another off-host destination) via a cron container is the minimum bar. The DB volume in the compose above is what survives container restarts; without backups it does not survive a VPS disk failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Watch the ingest rate limit.&lt;/strong&gt; Per &lt;a href="https://www.bugsink.com/docs/ingestion-rate-limits-and-retention/" rel="noopener noreferrer"&gt;BugSink's ingestion rate limit docs&lt;/a&gt;, there are project-level limits to keep one noisy app from drowning out the rest. Configure these before an app starts throwing 500 errors at 100/sec.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Resource limits on the web container.&lt;/strong&gt; The compose above already includes a 1G memory / 1 CPU cap on the web service. Tune up if you're ingesting heavy traffic — BugSink's event digestion isn't free, and unbounded growth on a $5 VPS will OOM the whole box (including Postgres).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Rate-limit the ingest endpoint at Nginx.&lt;/strong&gt; BugSink will rate-limit at the application layer per the link above, but a Nginx &lt;code&gt;limit_req&lt;/code&gt; zone on &lt;code&gt;/api/&lt;/code&gt; (or wherever the SDK posts events) acts as a cheap pre-filter against accidental loops in misconfigured clients.&lt;/p&gt;

&lt;p&gt;For the deploys themselves, &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 graduate to multiple replicas behind a load balancer or move BugSink to a bigger box, the rollout pattern stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tying BugSink issues to DeployHQ deploys
&lt;/h2&gt;

&lt;p&gt;Because BugSink speaks the Sentry release API, the existing &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; &lt;a href="https://dev.to/deployhq/tie-every-sentry-error-to-the-deployhq-deploy-that-caused-it-hfm"&gt;Sentry deploy tracking integration&lt;/a&gt; can in principle point at a BugSink installation — same &lt;code&gt;project:releases&lt;/code&gt; scope, same release-creation endpoint shape. If your team relied on Sentry's release-tagged regressions before, you can keep that workflow with BugSink as the backend. (Test before relying on it in production — Sentry SDK compatibility is BugSink's headline claim, but the management API surface has edges; verify against your specific version.)&lt;/p&gt;

&lt;p&gt;If you'd rather not test that route, the same outcome falls out of including a &lt;code&gt;release&lt;/code&gt; value in every SDK init that matches the commit SHA your build pipeline already exposes. BugSink groups issues by release automatically.&lt;/p&gt;

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

&lt;p&gt;The setup above is a working production install for a single low-to-medium-traffic team. A few paths from here, depending on what you actually need next:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More self-hosted observability on the same VPS pattern.&lt;/strong&gt; The same Docker Compose + Nginx + &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; pattern fits a long list of self-hostable tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/deployhq/deploy-hermes-agent-on-a-vps-deployhq-workflow-how-it-differs-from-openclaw-445"&gt;Hermes Agent on a VPS&lt;/a&gt; — self-improving agent runtime with persistent skills&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/deployhq/self-hosting-paperclip-on-a-vps-with-docker-and-continuous-deployment-4hh5-temp-slug-5506684"&gt;Self-host the Paperclip orchestrator on a VPS&lt;/a&gt; — agent orchestrator with a Docker-first install&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.deployhq.com/blog/self-host-immich-vps-docker-deployhq" rel="noopener noreferrer"&gt;Self-host Immich on a VPS&lt;/a&gt; — open-source Google Photos alternative&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/deployhq/self-host-vaultwarden-bitwarden-on-a-vps-with-docker-4m5g-temp-slug-3714068"&gt;Self-host Vaultwarden on a VPS&lt;/a&gt; — open-source Bitwarden server&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/deployhq/how-to-deploy-your-first-ai-agent-to-a-vps-with-docker-3peb-temp-slug-8709254"&gt;Deploy your first AI agent to a VPS with Docker&lt;/a&gt; — minimal FastAPI agent in the same shape&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Or stick with hosted error tracking and add deploy tracking.&lt;/strong&gt; If self-hosting is one bridge too far and you'd rather stay on Sentry, Bugsnag, Rollbar, or Honeybadger, the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; integration pattern is identical to what you just built — one config block in &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, every release shows up in the dashboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sentry deploy tracking with &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; (see the integration linked above)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.deployhq.com/blog/track-deployments-in-a-bugsnag-project" rel="noopener noreferrer"&gt;Bugsnag deploy tracking with DeployHQ&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.deployhq.com/blog/rollbar-notifications" rel="noopener noreferrer"&gt;Rollbar deploy tracking with DeployHQ&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/deployhq/tie-every-honeybadger-error-to-the-deployhq-deploy-that-caused-it-5f8n-temp-slug-1730876"&gt;Honeybadger deploy tracking with DeployHQ&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point isn't which backend you pick — it's that error tracking, the deploy that introduced each error, and a one-click rollback should all live in the same workflow. BugSink + &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; gets you there for the price of a VPS.&lt;/p&gt;




&lt;p&gt;Ready to put error tracking on infrastructure you control? &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 have BugSink shipping from &lt;code&gt;git push&lt;/code&gt; in under thirty minutes.&lt;/p&gt;

&lt;p&gt;Need a hand? 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>devopsinfrastructure</category>
      <category>docker</category>
      <category>tutorials</category>
      <category>vps</category>
    </item>
    <item>
      <title>Why appleboy/ssh-action and SCP Scripts Break for Real Deployments</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Fri, 24 Jul 2026 14:18:39 +0000</pubDate>
      <link>https://dev.to/deployhq/why-appleboyssh-action-and-scp-scripts-break-for-real-deployments-2312</link>
      <guid>https://dev.to/deployhq/why-appleboyssh-action-and-scp-scripts-break-for-real-deployments-2312</guid>
      <description>&lt;p&gt;Almost every team that deploys to its own servers arrives at the same place. You add a &lt;code&gt;deploy&lt;/code&gt; job to your CI pipeline, drop in a community SSH action or a hand-written &lt;code&gt;scp&lt;/code&gt; command, paste your key into a secret, and it works. The first green checkmark feels like you've solved deployment.&lt;/p&gt;

&lt;p&gt;Then the pipeline starts costing you. Not all at once — in small, hard-to-reproduce ways that surface at the worst time, usually during an incident when you most need the deploy path to be boring.&lt;/p&gt;

&lt;p&gt;This is a look at &lt;em&gt;why&lt;/em&gt; the &lt;q&gt;just SSH in from CI&lt;/q&gt; pattern erodes, using the failure modes teams actually hit, and where a dedicated deployment layer earns its place over a pipeline step. The same brittleness shows up whether you push over SSH or FTP from CI — here we focus on the SSH side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that everyone starts with
&lt;/h2&gt;

&lt;p&gt;The canonical setup is a workflow step built on something like &lt;code&gt;appleboy/ssh-action&lt;/code&gt;, or a hand-rolled &lt;code&gt;scp&lt;/code&gt;/&lt;code&gt;rsync&lt;/code&gt; transfer (each with its own &lt;a href="https://dev.to/alex_morgan_bf025b7a137df/sftp-vs-scp-vs-rsync-choosing-the-right-file-transfer-method-1bhm-temp-slug-2932016"&gt;tradeoffs between SFTP, SCP, and rsync&lt;/a&gt;), triggered on push to &lt;code&gt;main&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="pi"&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;Deploy&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.HOST }}&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.USER }}&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;cd /var/www/app&lt;/span&gt;
      &lt;span class="s"&gt;git pull&lt;/span&gt;
      &lt;span class="s"&gt;composer install --no-dev&lt;/span&gt;
      &lt;span class="s"&gt;php artisan migrate --force&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Nothing here is wrong on day one. The problem is that this step is quietly doing several jobs at once — authentication, transport, remote execution, release logic, and error handling — with none of them owned by anything that understands &lt;em&gt;deployment&lt;/em&gt;. It's a shell script wearing a CI badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 1: secrets that don't mask cleanly
&lt;/h2&gt;

&lt;p&gt;CI systems mask registered secrets in logs by replacing the exact secret string. That guarantee gets thin the moment your secret is a composite value. A &lt;code&gt;host:port&lt;/code&gt; secret, for example, may only be partially redacted — the masker matches the whole string, not the &lt;code&gt;host&lt;/code&gt; fragment that also appears elsewhere in output — so part of the value leaks into the log.&lt;/p&gt;

&lt;p&gt;Mask &lt;em&gt;more&lt;/em&gt; aggressively and you trade one problem for another: if every host string is scrubbed across a multi-server deploy, the logs become ambiguous. You can no longer tell which server produced which line, which is exactly the information you need when one host in the fleet fails and the rest succeed. Coordinating that cleanly — &lt;a href="https://www.deployhq.com/blog/deploying-to-multiple-servers-at-the-same-time" rel="noopener noreferrer"&gt;deploying to multiple servers in parallel without version drift&lt;/a&gt; — is its own discipline that raw CI logs don't support.&lt;/p&gt;

&lt;p&gt;That's the tension. Raw CI logs weren't designed to be a deployment audit trail, and bending them into one fights the masking layer both directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 2: no concept of a release
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git pull &amp;amp;&amp;amp; composer install&lt;/code&gt; mutates your live directory in place. There is no atomic switch, no discrete artifact, and no clean &lt;q&gt;before&lt;/q&gt; to return to. If the build half-completes — a dependency fetch times out, a migration partially applies — you are now serving a site that is neither the old version nor the new one.&lt;/p&gt;

&lt;p&gt;Rollback in this world means &lt;em&gt;another&lt;/em&gt; forward deploy: check out the previous SHA, reinstall, re-migrate, and hope the down-migration is as clean as you assumed when you wrote it at 3pm on a calm Tuesday. Teams that lean on this pattern almost always describe rollback as &lt;q&gt;by memory&lt;/q&gt; — there's no retained artifact to flip back to because the model never produced one. Atomic releases with a retained history and a &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;one-click rollback&lt;/a&gt; exist precisely because &lt;q&gt;just deploy the old commit again&lt;/q&gt; is not a recovery strategy under pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 3: the runner is not a stable machine
&lt;/h2&gt;

&lt;p&gt;Hosted runners are ephemeral and clean, which sounds like a feature until your deploy depends on state. Self-hosted runners are the opposite: they persist, so they accumulate. Leftover SSH agents, a &lt;code&gt;known_hosts&lt;/code&gt; file from a decommissioned server, a cached key, half-cleaned working directories — residue from previous runs that makes a deploy behave differently depending on &lt;em&gt;which&lt;/em&gt; runner picked it up and what the last job left behind.&lt;/p&gt;

&lt;p&gt;The result is the least debuggable class of failure: the pipeline is green on one runner and red on another, with the same commit. You end up debugging the CI environment instead of your application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 4: transport and orchestration are tangled together
&lt;/h2&gt;

&lt;p&gt;A single &lt;code&gt;ssh-action&lt;/code&gt; step is authentication, file transfer, remote shell, and release orchestration fused into one opaque block. When it fails, the error surface is a shell exit code and a wall of stdout. Was it the connection? A key permission? A failed &lt;code&gt;composer&lt;/code&gt; step on the remote? A timeout mid-transfer? The step can't tell you, because it doesn't model those as distinct phases — it just runs a script and reports whether the script exited non-zero.&lt;/p&gt;

&lt;p&gt;Pulling these concerns apart is most of what a real deployment tool does. When &lt;a href="https://www.deployhq.com/blog/github-sftp" rel="noopener noreferrer"&gt;deployments run over SSH and SFTP through a system that models them&lt;/a&gt;, connection, build, transfer, and remote commands are separate, individually logged steps — so a failure points at a stage instead of a stack trace.&lt;/p&gt;




&lt;h2&gt;
  
  
  What actually changes when deployment is its own layer
&lt;/h2&gt;

&lt;p&gt;The fix is not &lt;q&gt;write a better bash script.&lt;/q&gt; It's moving the responsibilities that don't belong in a CI job out of it. GitHub Actions is a genuinely good CI system — running tests, building artifacts, gating merges (if that's the part you want to get right, here's how to &lt;a href="https://dev.to/theqadiariesforyou/cicd-pipelines-the-complete-guide-576p-temp-slug-9315489"&gt;build a CI/CD pipeline with GitHub Actions&lt;/a&gt;). Deployment is a different job with different requirements: credentials that live at the deployment layer instead of in pipeline secrets, per-server logs you can actually read, build steps that run off your production host, and a release model with history and rollback built in. That's the line the &lt;a href="https://www.deployhq.com/compare/deployhq-vs-github-actions" rel="noopener noreferrer"&gt;DeployHQ vs GitHub Actions comparison&lt;/a&gt; draws in detail — CI to prove the code is good, a &lt;a href="https://www.deployhq.com/features/automatic-deployments" rel="noopener noreferrer"&gt;dedicated deployment platform&lt;/a&gt; to ship it safely.&lt;/p&gt;

&lt;p&gt;Concretely, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credentials and known hosts live in one place&lt;/strong&gt; , not copied into every repo's secrets, not re-provisioned on ephemeral runners, and not a matter of &lt;a href="https://www.deployhq.com/blog/5-ways-to-create-ssh-keys-from-the-command-line-for-deployhq" rel="noopener noreferrer"&gt;generating SSH keys from the command line&lt;/a&gt; for every new target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build commands run in an isolated environment&lt;/strong&gt; through a proper &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipeline&lt;/a&gt;, so a failed &lt;code&gt;composer install&lt;/code&gt; never leaves your live server half-updated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every server gets its own readable deploy log&lt;/strong&gt; , so a multi-host deploy tells you exactly which target failed — no log-masking guesswork.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Releases are atomic and retained&lt;/strong&gt; , so recovery is flipping back, not re-deriving the previous state — the foundation of &lt;a href="https://dev.to/deployhq/zero-downtime-deployments-keeping-your-application-running-smoothly-hia"&gt;atomic, zero-downtime release strategies&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've been staring at a red deploy job trying to work out whether it was the network, the key, or the remote build that broke, that ambiguity is the tax. &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Start with a project and connect your server&lt;/a&gt; and the SSH glue stops being something you maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the CI-only pattern is genuinely fine
&lt;/h2&gt;

&lt;p&gt;To be fair to the humble &lt;code&gt;ssh-action&lt;/code&gt;: if you deploy a single static site to a single box, downtime during deploy is acceptable, and you rarely roll back, the pattern is proportionate. Don't add a deployment platform to a personal blog. The calculus changes the moment you have more than one server, a build step that can fail, real users who notice a broken release, or a teammate who needs to understand what shipped without reading raw pipeline output. That's when the deploy step wants to become a deployment &lt;em&gt;system&lt;/em&gt;. If you're comparing the options for a single box, &lt;a href="https://dev.to/deployhq/whats-the-easiest-way-to-deploy-on-a-vps-21af"&gt;the easiest way to deploy on a VPS&lt;/a&gt; weighs manual SSH, Capistrano, and managed deploys side by side.&lt;/p&gt;

&lt;p&gt;Deploying over SSH from CI isn't a mistake — it's a stage most teams pass through. The mistake is staying there after your setup has outgrown it.&lt;/p&gt;




&lt;p&gt;Questions about moving a brittle CI deploy onto a managed path? Email 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/Twitter @deployhq&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devopsinfrastructure</category>
      <category>tipstricks</category>
    </item>
    <item>
      <title>Deploy a Modern Laravel App to cPanel Shared Hosting (No SSH)</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Fri, 24 Jul 2026 14:13:59 +0000</pubDate>
      <link>https://dev.to/deployhq/deploy-a-modern-laravel-app-to-cpanel-shared-hosting-no-ssh-3ih7</link>
      <guid>https://dev.to/deployhq/deploy-a-modern-laravel-app-to-cpanel-shared-hosting-no-ssh-3ih7</guid>
      <description>&lt;p&gt;If you build with a modern Laravel stack — Inertia, React or Vue, Tailwind, a Vite build step — and your client insists on their existing cPanel shared hosting, you've probably hit the wall this post is about. The framework assumes a build toolchain and often SSH. The host gives you neither. Every &lt;q&gt;just run &lt;code&gt;composer install&lt;/code&gt; on the server&lt;/q&gt; tutorial quietly assumes access you don't have.&lt;/p&gt;

&lt;p&gt;You don't actually need that access. The trick is to stop treating the shared host as a place where building happens, and start treating it as a place where &lt;em&gt;finished files land&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the usual advice fails on shared hosting
&lt;/h2&gt;

&lt;p&gt;The standard deploy recipe for a Laravel VPS is: SSH in, &lt;code&gt;git pull&lt;/code&gt;, &lt;code&gt;composer install&lt;/code&gt;, &lt;code&gt;npm run build&lt;/code&gt;, &lt;code&gt;php artisan migrate&lt;/code&gt;. On cPanel shared hosting, that chain breaks at almost every link:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No SSH.&lt;/strong&gt; Many shared plans expose only FTP/SFTP and the cPanel UI. There's no shell to run commands in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composer won't install (or won't run).&lt;/strong&gt; Memory limits, missing CLI PHP, no write access outside your home directory, or no way to invoke it at all. This is one of the most common shared-hosting complaints, and it's usually not fixable from your side. Running Composer where the build actually belongs — off the shared host — is the approach in &lt;a href="https://www.deployhq.com/blog/deploying-your-php-site-with-deployhq" rel="noopener noreferrer"&gt;deploying a PHP site with Composer off-host&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Node build.&lt;/strong&gt; Vite/webpack need a build environment the host doesn't provide, so your compiled CSS/JS never gets generated where the site actually runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connecting Git is fiddly.&lt;/strong&gt; cPanel's built-in Git Version Control ties awkwardly to a single deploy key, which is why people hit walls trying to connect &lt;em&gt;multiple&lt;/em&gt; private repositories to one cPanel account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The instinct is to fight each of these on the server. The better move is to not run any of them on the server at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build off-host, deploy the artifact
&lt;/h2&gt;

&lt;p&gt;The model that works on constrained hosting separates the two jobs the shared host can't do from the one it can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build somewhere with a real toolchain&lt;/strong&gt; — install Composer dependencies, run the Vite/npm build, cache config, generate the optimized autoloader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship only the finished result&lt;/strong&gt; to the host over FTP/SFTP.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shared host never sees Composer or Node. It receives a directory of ready-to-serve files, which is the one thing every cPanel plan &lt;em&gt;can&lt;/em&gt; accept. This is the whole idea behind a &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;managed build pipeline&lt;/a&gt;: your build commands run in an isolated environment on the deployment side, and only the compiled output is transferred to the &lt;a href="https://www.deployhq.com/features/deployment-targets" rel="noopener noreferrer"&gt;FTP/SFTP or cPanel-style target&lt;/a&gt; you configure.&lt;/p&gt;

&lt;p&gt;Compared to wiring cPanel's own Git tooling to your repositories, this sidesteps the single-deploy-key limitation entirely — the platform connects to your repository centrally, so multiple private repos and multiple targets stop being a plumbing exercise. If you're weighing the two approaches directly, the &lt;a href="https://www.deployhq.com/compare/deployhq-vs-cpanel" rel="noopener noreferrer"&gt;DeployHQ vs cPanel deployment comparison&lt;/a&gt; lays out where each fits. For the full sequence of build steps and release hygiene on this stack specifically, see &lt;a href="https://dev.to/theqadiariesforyou/how-to-deploy-laravel-zero-downtime-build-pipelines-and-best-practices-1jcj-temp-slug-5099175"&gt;Laravel build-pipeline deployment best practices&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A no-SSH Laravel deployment, end to end
&lt;/h2&gt;

&lt;p&gt;Here's the shape of it, assuming a Laravel app with an Inertia/React front end on a cPanel shared plan that only offers FTP/SFTP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Point deployment at your repository.&lt;/strong&gt; Connect the Git repo once. Every push to your deploy branch becomes a candidate release — no cPanel Git key juggling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Define the build commands.&lt;/strong&gt; These run off-host, not on cPanel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt;
npm ci
npm run build
php artisan config:cache
php artisan route:cache

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

&lt;/div&gt;



&lt;p&gt;The output — &lt;code&gt;vendor/&lt;/code&gt;, the built &lt;code&gt;public/build/&lt;/code&gt; assets, cached config — becomes the artifact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Configure the FTP/SFTP target.&lt;/strong&gt; Add the host's FTP or SFTP credentials as a server. The &lt;a href="https://www.deployhq.com/blog/using-deployhq-with-shared-hosting-providers" rel="noopener noreferrer"&gt;shared-hosting deployment setup&lt;/a&gt; covers the target configuration, and the &lt;a href="https://www.deployhq.com/blog/how-to-deploy-to-your-ftp-server-with-git-and-deployhq" rel="noopener noreferrer"&gt;step-by-step FTP-from-Git walkthrough&lt;/a&gt; shows the mechanics if FTP is all you've got. Prefer SFTP where the host offers it — the &lt;a href="https://www.deployhq.com/blog/how-to-deploy-to-your-server-using-ssh-sftp-and-git-with-deployhq" rel="noopener noreferrer"&gt;same setup over SSH/SFTP&lt;/a&gt; is worth the small extra effort for the encrypted transport.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Handle Laravel's public-directory quirk.&lt;/strong&gt; On shared hosting the web root is usually &lt;code&gt;public_html&lt;/code&gt;, not your app's &lt;code&gt;public/&lt;/code&gt;. Map the deploy so the built &lt;code&gt;public/&lt;/code&gt; contents land in &lt;code&gt;public_html&lt;/code&gt;, and keep the framework files above the web root. This is a mapping decision, not a code change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;Deploy&lt;/a&gt;.&lt;/strong&gt; Push to your branch. The build runs off-host, the finished files transfer to &lt;code&gt;public_html&lt;/code&gt;, and the site updates — with a deploy log for each run, so a failed transfer tells you what happened instead of leaving you guessing.&lt;/p&gt;

&lt;p&gt;If that sequence is where you're stuck right now, &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;connecting a repository and a shared-hosting target&lt;/a&gt; is the fastest way to see it work against your own setup.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same pattern isn't Laravel-specific
&lt;/h2&gt;

&lt;p&gt;Everything above is really about &lt;em&gt;any&lt;/em&gt; build-step framework meeting a host that can't build. The same off-host-build approach applies to a Drupal site where Composer won't run on shared hosting (see the &lt;a href="https://www.deployhq.com/guides/laravel" rel="noopener noreferrer"&gt;Laravel deployment guide&lt;/a&gt; for framework specifics), a static-site generator, or a Vue SPA. It works just as well when you &lt;a href="https://dev.to/alex_morgan_da8d0c98b02a1/how-to-automate-wordpress-deployments-with-git-no-more-ftp-obk-temp-slug-3699271"&gt;deploy WordPress from Git without FTP uploads&lt;/a&gt;. The constraint — &lt;q&gt;the host serves files but can't assemble them&lt;/q&gt; — is the same, and so is the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is shared hosting still the right home?
&lt;/h2&gt;

&lt;p&gt;Sometimes the honest answer is that the constraint is worth removing rather than working around. If you're regularly fighting the host's limits, &lt;a href="https://dev.to/deployhq/5-signs-its-time-to-upgrade-from-shared-hosting-to-automated-deployments-2e8o"&gt;the signs it's time to move off shared hosting to automated deployments&lt;/a&gt; is a useful gut-check. And if you've been told FTP is obsolete, the reality is more nuanced — &lt;a href="https://www.deployhq.com/blog/is-ftp-dead-a-look-at-its-continued-use-in-deployment" rel="noopener noreferrer"&gt;FTP's continued, legitimate role in deployment&lt;/a&gt; covers where it still earns its place. But when the client's cPanel plan is a fixed requirement, building off-host and deploying the artifact is how you ship a modern app onto it without asking the host for access it will never give you.&lt;/p&gt;




&lt;p&gt;Stuck on a specific no-SSH deployment? Email &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or ask us on &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;X/Twitter @deployhq&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>tutorials</category>
    </item>
    <item>
      <title>How to Run WordPress on AWS: EC2, Lightsail &amp; Git Deploys</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Wed, 22 Jul 2026 07:14:29 +0000</pubDate>
      <link>https://dev.to/deployhq/how-to-run-wordpress-on-aws-ec2-lightsail-git-deploys-2kh5</link>
      <guid>https://dev.to/deployhq/how-to-run-wordpress-on-aws-ec2-lightsail-git-deploys-2kh5</guid>
      <description>&lt;p&gt;Running WordPress on AWS gives you something shared hosting never will: full control over the machine, the network, and the database that serve your site. But that control comes with decisions most tutorials skip — which AWS compute service to pick, how to lock down your firewall, whether to run MySQL on the box or hand it to a managed service, and how to keep the IP address from changing every time you reboot. This guide walks through the whole path: choosing between EC2, Lightsail, and Elastic Beanstalk, provisioning and securing the instance, installing WordPress, and then automating your theme and plugin deploys from Git so you never SSH in to drag files around again.&lt;/p&gt;

&lt;p&gt;We'll focus on the decisions that actually bite you in production, not the happy-path clicks you can find anywhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Choose your AWS compute — EC2 vs Lightsail vs Elastic Beanstalk
&lt;/h2&gt;

&lt;p&gt;AWS gives you at least three sensible ways to host WordPress, and the right one depends on how much of the underlying machine you want to manage.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Pricing model&lt;/th&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lightsail&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A single WordPress site, predictable cost, minimal ops&lt;/td&gt;
&lt;td&gt;Fixed monthly (bundle)&lt;/td&gt;
&lt;td&gt;Medium — AWS manages the plumbing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;EC2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full control, custom stacks, multiple sites, tuning&lt;/td&gt;
&lt;td&gt;Pay-as-you-go (per second)&lt;/td&gt;
&lt;td&gt;Full — it's your server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Elastic Beanstalk&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Managed platform with autoscaling built in&lt;/td&gt;
&lt;td&gt;Pay for the underlying resources&lt;/td&gt;
&lt;td&gt;Low-to-medium — AWS runs the platform&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Amazon Lightsail&lt;/strong&gt; is the simplest entry point. It's EC2 underneath, but wrapped in a fixed-price bundle (compute + storage + a generous data transfer allowance) with a friendlier console. You get a static IP, DNS management, and one-click blueprints. For a typical single WordPress site, this is the pragmatic default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amazon EC2&lt;/strong&gt; hands you a raw virtual server and gets out of the way. You pick the operating system, install exactly the stack you want, and pay only for what you run. Choose EC2 when you need a custom stack (specific PHP or Nginx tuning), plan to host several sites on one box, or want to attach services like Amazon RDS and ElastiCache directly. If you want to walk the full server build, our guide to &lt;a href="https://www.deployhq.com/guides/amazon-ec2" rel="noopener noreferrer"&gt;deploy to Amazon EC2&lt;/a&gt; covers the connection setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elastic Beanstalk&lt;/strong&gt; is a managed platform layer that provisions and autoscales the infrastructure for you from an application bundle. It shines when traffic is spiky and you want load balancing and autoscaling without wiring them by hand — but it adds abstraction that a single WordPress site rarely needs, and it's a different mental model from a plain server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our recommendation:&lt;/strong&gt; start on &lt;strong&gt;Lightsail&lt;/strong&gt; if you want fixed cost and low ops, or &lt;strong&gt;EC2&lt;/strong&gt; if you want full control. Reach for Beanstalk only when autoscaling is a hard requirement.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Provision and secure the instance
&lt;/h2&gt;

&lt;p&gt;Whether you land on Lightsail or EC2, the provisioning checklist is the same: pick an image, size it, open the right ports, and pin the IP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pick the image (AMI or blueprint)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On Lightsail:&lt;/strong&gt; choose a blueprint. The &lt;strong&gt;Bitnami WordPress&lt;/strong&gt; blueprint gives you a fully installed LAMP + WordPress stack in a couple of minutes. If you'd rather build the stack yourself, pick an &lt;strong&gt;OS-only&lt;/strong&gt; blueprint like Ubuntu 24.04 LTS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On EC2:&lt;/strong&gt; pick an &lt;strong&gt;AMI&lt;/strong&gt;. Ubuntu Server 24.04 LTS or Amazon Linux 2023 are both solid. Start clean and install the stack yourself for maximum control.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Size it honestly
&lt;/h3&gt;

&lt;p&gt;A small WordPress site runs comfortably on a &lt;strong&gt;2 GB RAM&lt;/strong&gt; instance — an EC2 &lt;code&gt;t3.small&lt;/code&gt; or the equivalent Lightsail bundle. The 1 GB tier works but leaves little headroom once you add plugins and a page cache. You can resize an EC2 instance later by stopping it and changing the type, so don't over-buy on day one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lock down the firewall (this is where people get burned)
&lt;/h3&gt;

&lt;p&gt;On EC2 this is the &lt;strong&gt;security group&lt;/strong&gt; ; on Lightsail it's the instance &lt;strong&gt;firewall&lt;/strong&gt;. Configure inbound rules deliberately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Source&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;80&lt;/td&gt;
&lt;td&gt;TCP (HTTP)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0/0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public web traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;TCP (HTTPS)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0/0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public secure traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;TCP (SSH)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Your IP only&lt;/strong&gt; (&lt;code&gt;x.x.x.x/32&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Admin access&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gotcha that bites everyone: &lt;strong&gt;do not open port 22 to &lt;code&gt;0.0.0.0/0&lt;/code&gt;.&lt;/strong&gt; Leaving SSH open to the entire internet invites constant brute-force attempts. Restrict the source to your own IP (or a VPN range). Ports 80 and 443 are meant to be public; port 22 is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pin the IP with a static/Elastic IP
&lt;/h3&gt;

&lt;p&gt;By default, an EC2 instance's public IP &lt;strong&gt;changes every time you stop and start it&lt;/strong&gt;. That will silently break your DNS and your site. Allocate an &lt;strong&gt;Elastic IP&lt;/strong&gt; and associate it with the instance so the address stays put. On Lightsail, attach a &lt;strong&gt;static IP&lt;/strong&gt; — same idea, one click. Do this before you point your domain's DNS at the box.&lt;/p&gt;

&lt;h3&gt;
  
  
  Set up SSH access
&lt;/h3&gt;

&lt;p&gt;Create (or upload) an SSH key pair when you launch the instance and download the private key. You'll connect with it, and — importantly — you'll hand the same kind of key to your deployment tool later:&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;# Connect to your instance (Ubuntu AMI uses the "ubuntu" user)&lt;/span&gt;
ssh &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/wordpress-aws.pem ubuntu@&amp;lt;your-elastic-ip&amp;gt;

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

&lt;/div&gt;



&lt;p&gt;If you provisioned Lightsail's Bitnami blueprint, &lt;a href="https://www.deployhq.com/guides/aws-lightsail" rel="noopener noreferrer"&gt;deploy to AWS Lightsail&lt;/a&gt; documents where the WordPress files and credentials live on that image.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Install the stack and WordPress
&lt;/h2&gt;

&lt;p&gt;You have two honest paths here, depending on the image you chose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Path A — Pre-baked Bitnami (Lightsail)
&lt;/h3&gt;

&lt;p&gt;If you picked the Bitnami WordPress blueprint, WordPress is already installed and serving. Grab the admin password from the instance (it's in &lt;code&gt;/home/bitnami/bitnami_credentials&lt;/code&gt;), log in at &lt;code&gt;/wp-admin&lt;/code&gt;, and you're essentially done with the stack. This is the fastest route to a running site.&lt;/p&gt;

&lt;h3&gt;
  
  
  Path B — Build a LEMP stack yourself (EC2 or OS-only Lightsail)
&lt;/h3&gt;

&lt;p&gt;For full control, install a &lt;strong&gt;LEMP&lt;/strong&gt; stack (Linux, Nginx, MySQL, PHP-FPM) on Ubuntu:&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;# 1. Update packages&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# 2. Install Nginx, MySQL, PHP-FPM and common WordPress extensions&lt;/span&gt;
&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 mysql-server &lt;span class="se"&gt;\&lt;/span&gt;
  php-fpm php-mysql php-curl php-gd php-xml php-mbstring

&lt;span class="c"&gt;# 3. Secure the MySQL install (set root password, remove test DB)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mysql_secure_installation

&lt;span class="c"&gt;# 4. Create the WordPress database and a dedicated user&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"CREATE DATABASE wordpress;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"CREATE USER 'wp_user'@'localhost' IDENTIFIED BY '&amp;lt;strong-password&amp;gt;';"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"GRANT ALL ON wordpress.* TO 'wp_user'@'localhost'; FLUSH PRIVILEGES;"&lt;/span&gt;

&lt;span class="c"&gt;# 5. Download and place WordPress&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /tmp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-O&lt;/span&gt; https://wordpress.org/latest.tar.gz
&lt;span class="nb"&gt;tar &lt;/span&gt;xzf latest.tar.gz
&lt;span class="nb"&gt;sudo mv &lt;/span&gt;wordpress /var/www/wordpress
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; www-data:www-data /var/www/wordpress

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

&lt;/div&gt;



&lt;p&gt;From there, point an Nginx server block at &lt;code&gt;/var/www/wordpress&lt;/code&gt;, copy &lt;code&gt;wp-config-sample.php&lt;/code&gt; to &lt;code&gt;wp-config.php&lt;/code&gt; with your database credentials, and finish the famous five-minute install in the browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  The real decision: local MySQL vs Amazon RDS
&lt;/h3&gt;

&lt;p&gt;Where your database lives is the tradeoff worth thinking about before you go live.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local MySQL&lt;/strong&gt; (on the same instance) is free, simple, and fast because there's no network hop. But you own everything: backups, tuning, patching, and recovery. If the instance dies, so does the database unless you've been backing it up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amazon RDS&lt;/strong&gt; (managed MySQL) costs extra — you're paying for a separate managed instance — but you get automated backups, point-in-time recovery, easy version upgrades, and optional Multi-AZ failover. It also decouples the database from the web server, so you can resize or replace the instance without touching your data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a small hobby or brochure site, local MySQL is fine. For anything where the data matters — a store, a client site, a business — RDS pays for itself the first time you need a clean restore. Point &lt;code&gt;wp-config.php&lt;/code&gt; at the RDS endpoint instead of &lt;code&gt;localhost&lt;/code&gt; and WordPress doesn't know the difference.&lt;/p&gt;




&lt;p&gt;Once your site is live, the last thing you want is to keep logging in over SSH to push theme edits by hand. Ready to put deploys on autopilot? &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Start on the free plan or a 10-day trial&lt;/a&gt; and connect your repository in a few minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: The bridge — version your code in Git and automate deploys
&lt;/h2&gt;

&lt;p&gt;Here's the part that separates a hobby setup from a maintainable one. Your WordPress &lt;strong&gt;theme and custom plugin code&lt;/strong&gt; is real software, and it belongs in a Git repository — not edited live on the server through the WordPress admin. Once it's in Git, you can push changes automatically to your AWS instance the moment you merge, with none of the manual SFTP shuffling.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; connects to your GitHub, GitLab, or Bitbucket repo and ships your code to the EC2 or Lightsail instance over &lt;strong&gt;SSH/SFTP&lt;/strong&gt; — the same key-based access you set up in Step 2. On each commit to your chosen branch, it can run a &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipeline&lt;/a&gt; (compile assets, run &lt;code&gt;composer install&lt;/code&gt;, minify CSS) on a separate build server, then transfer only the changed files. It performs &lt;a href="https://www.deployhq.com/features/atomic-deployments" rel="noopener noreferrer"&gt;atomic deployments&lt;/a&gt; — releasing into a fresh directory and swapping a symlink so visitors never see a half-uploaded site — and gives you &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;one-click rollback&lt;/a&gt; if a release misbehaves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Push to main branch] --&amp;gt; B[DeployHQ detects commit]
    B --&amp;gt; C[Run build pipeline on build server]
    C --&amp;gt; D[Transfer changed files over SSH]
    D --&amp;gt; E[Atomic release: symlink swap]
    E --&amp;gt; F[WordPress site live on AWS instance]

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why automate instead of SSHing in?&lt;/strong&gt; Because manual file drags cause half-deployed states, are impossible to reproduce across a team, and have no undo. Automated deploys are consistent, atomic, and reversible — and they let you run build steps that manual uploads can't.&lt;/p&gt;

&lt;p&gt;We won't re-teach the full pipeline setup here — the step-by-step lives in our guide to &lt;a href="https://dev.to/alex_morgan_da8d0c98b02a1/how-to-automate-wordpress-deployments-with-git-no-more-ftp-obk-temp-slug-3699271"&gt;automate WordPress deployments from Git&lt;/a&gt;, which covers connecting the repo and configuring the transfer. For the broader concepts, our overview of &lt;a href="https://www.deployhq.com/blog/deployment-automation-a-quick-overview" rel="noopener noreferrer"&gt;deployment automation&lt;/a&gt; explains why this pattern matters for any stack, and if you're coming from a plain server the &lt;a href="https://dev.to/deployhq/deploying-a-wordpress-application-on-a-vps-a-beginners-guide-11mb"&gt;install WordPress on a VPS&lt;/a&gt; beginner's guide fills in the server-side basics. WordPress is PHP under the hood, so the same approach used to &lt;a href="https://www.deployhq.com/blog/deploying-your-php-site-with-deployhq" rel="noopener noreferrer"&gt;deploy a PHP site&lt;/a&gt; applies directly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Keep the database and uploads out of Git.&lt;/strong&gt; Version your themes, plugins, and configuration — not &lt;code&gt;wp-content/uploads&lt;/code&gt; or the database. Those are runtime data, and committing them causes conflicts and bloat. Use a &lt;a href="https://dev.to/deployhq/keeping-development-staging-and-production-in-sync-with-deployhq-1i10"&gt;keep development and production in sync&lt;/a&gt; workflow to manage content and media separately from code.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 5: Production notes — backups, HTTPS, and scaling
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Back up to S3
&lt;/h3&gt;

&lt;p&gt;Your instance is not a backup. Schedule regular dumps of the database and &lt;code&gt;wp-content/uploads&lt;/code&gt; to Amazon S3, which is cheap, durable, and off-instance:&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;# Nightly database backup pushed to S3 (add to cron)&lt;/span&gt;
mysqldump wordpress | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/wp-&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.gz
aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; /tmp/wp-&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.gz s3://my-wordpress-backups/db/

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

&lt;/div&gt;



&lt;p&gt;Our walkthrough on how to &lt;a href="https://www.deployhq.com/blog/how-to-implement-server-backups-with-aws-s3" rel="noopener noreferrer"&gt;back up your server to AWS S3&lt;/a&gt; covers automating this properly with lifecycle rules so old backups age out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enable HTTPS
&lt;/h3&gt;

&lt;p&gt;For a certificate that lives &lt;strong&gt;on the instance&lt;/strong&gt; (Nginx or Apache serving directly), use &lt;strong&gt;Let's Encrypt&lt;/strong&gt; via &lt;a href="https://certbot.eff.org/" rel="noopener noreferrer"&gt;Certbot&lt;/a&gt; — it's free and auto-renews:&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 &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; certbot python3-certbot-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; yourdomain.com &lt;span class="nt"&gt;-d&lt;/span&gt; www.yourdomain.com

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

&lt;/div&gt;



&lt;p&gt;A common gotcha: &lt;strong&gt;AWS Certificate Manager (ACM)&lt;/strong&gt; certificates are free too, but you &lt;strong&gt;cannot install them directly on an EC2 instance&lt;/strong&gt;. ACM certs only attach to AWS-managed endpoints like an Application Load Balancer or CloudFront. If you're serving straight from the box, use &lt;a href="https://letsencrypt.org/" rel="noopener noreferrer"&gt;Let's Encrypt&lt;/a&gt;. Reach for ACM only when you put a load balancer or CDN in front.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scaling considerations
&lt;/h3&gt;

&lt;p&gt;When a single instance stops keeping up, scale in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vertical resize&lt;/strong&gt; — bump the instance to more RAM/CPU. Simplest first move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add caching&lt;/strong&gt; — a page cache plus an object cache (Redis via ElastiCache) removes most database pressure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offload media&lt;/strong&gt; — move &lt;code&gt;wp-content/uploads&lt;/code&gt; to S3 and serve it through CloudFront so the web server isn't shipping images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate the database&lt;/strong&gt; — move to Amazon RDS if you haven't, so the web tier and data tier scale independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go horizontal&lt;/strong&gt; — run multiple instances behind a load balancer across Availability Zones for redundancy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One clear boundary worth stating: &lt;strong&gt;the AWS-side architecture — Availability Zones, load balancers, multi-region redundancy — is something you design and provision on AWS.&lt;/strong&gt; DeployHQ's job is to ship your code reliably to the server or servers you've configured; it doesn't build your AWS topology for you. Keep those two responsibilities distinct and both jobs stay simple. If your growth path leads toward a managed platform with built-in autoscaling, we also cover how to &lt;a href="https://dev.to/deployhq/elevate-your-aws-deployments-introducing-deployhqs-aws-elastic-beanstalk-integration-57l9"&gt;deploy to AWS Elastic Beanstalk from Git&lt;/a&gt;.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Should I use EC2 or Lightsail for WordPress?&lt;/strong&gt; Use Lightsail if you want fixed monthly pricing and minimal server management for a single site — it's the pragmatic default. Choose EC2 when you need full control over the stack, plan to host multiple sites, or want to attach managed services like RDS and ElastiCache directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Amazon RDS, or can I run MySQL on the same instance?&lt;/strong&gt; Both work. Local MySQL is free and simple but you own backups and recovery. RDS costs extra but adds automated backups, point-in-time recovery, and optional failover. For any site where the data matters, RDS is worth it; for a hobby site, local MySQL is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my WordPress site's IP address keep changing?&lt;/strong&gt; Because an EC2 instance gets a new public IP each time it stops and starts. Allocate an Elastic IP (or a Lightsail static IP) and associate it with the instance so the address is permanent, then point your DNS at it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use a free AWS Certificate Manager certificate on my EC2 instance?&lt;/strong&gt; Not directly. ACM certificates only attach to AWS-managed endpoints like Application Load Balancers and CloudFront. To serve HTTPS straight from an EC2 or Lightsail instance, use a free Let's Encrypt certificate via Certbot instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I deploy theme and plugin changes without SSHing in every time?&lt;/strong&gt; Keep your theme and custom plugin code in a Git repository and connect it to a deployment tool that pushes to your instance over SSH on each commit. You get consistent, atomic releases with rollback — no manual SFTP, and no half-deployed sites.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Running WordPress on AWS is mostly about making a few decisions well: Lightsail or EC2 for compute, local MySQL or RDS for the database, a locked-down firewall, a pinned IP, and HTTPS that actually renews itself. Get those right and you have a fast, controllable host. Add automated deploys from Git and you also get a maintainable one — every change versioned, every release reversible.&lt;/p&gt;

&lt;p&gt;Ready to stop dragging files over SFTP? Connect your repository and &lt;a href="https://www.deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;deploy from GitHub to server&lt;/a&gt; in minutes. Questions? Email 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&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devopsinfrastructure</category>
      <category>tutorials</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>WordPress with Docker: A Local-to-Production Workflow (2026)</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Wed, 22 Jul 2026 07:14:17 +0000</pubDate>
      <link>https://dev.to/deployhq/wordpress-with-docker-a-local-to-production-workflow-2026-4p4d</link>
      <guid>https://dev.to/deployhq/wordpress-with-docker-a-local-to-production-workflow-2026-4p4d</guid>
      <description>&lt;p&gt;Setting up WordPress the old way meant installing PHP, a web server, and MySQL directly on your laptop, then praying the versions roughly matched whatever your host was running. They never did. A plugin that worked on your machine would throw a fatal error in production because the live server ran PHP 8.1 and you were on 8.4. Docker fixes that mismatch by packaging PHP, Apache, and the database into disposable containers you can spin up, tear down, and rebuild in seconds.&lt;/p&gt;

&lt;p&gt;This guide covers the part most tutorials skip: running WordPress in Docker &lt;em&gt;locally&lt;/em&gt; as your development environment, with a real &lt;code&gt;docker-compose.yml&lt;/code&gt; you can copy today. You'll get a working WordPress + database + phpMyAdmin stack, learn how to edit themes and plugins on a mounted volume, run WP-CLI inside a container, and — once your code lives in Git — hand the production step down to an automated deployment instead of Dockerizing your live site. By the end you'll have local-to-production parity without the &lt;q&gt;works on my machine&lt;/q&gt; tax.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why run WordPress in Docker locally
&lt;/h2&gt;

&lt;p&gt;Local WordPress development has three chronic problems, and containers solve all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment parity.&lt;/strong&gt; Production runs a specific PHP version, a specific MySQL or MariaDB version, and a specific set of extensions. When your local machine drifts from those versions, bugs appear only after you deploy. Pinning the same image tags locally that your host runs closes that gap. If you understand &lt;a href="https://www.deployhq.com/blog/what-is-docker" rel="noopener noreferrer"&gt;how Docker containers work&lt;/a&gt;, you already know the pitch: the container &lt;em&gt;is&lt;/em&gt; the environment, so it behaves identically on your laptop, a teammate's laptop, and CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No global pollution.&lt;/strong&gt; Installing PHP and MySQL system-wide means every project shares one runtime. Upgrade PHP for one client and you risk breaking another. Docker scopes everything to the project directory — delete the folder and the environment is gone, with nothing left behind in &lt;code&gt;/usr/local&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disposable, reproducible setups.&lt;/strong&gt; A new developer clones the repo, runs one command, and has a working site in under a minute. No wiki page of setup steps that rot the moment someone bumps a version. The &lt;code&gt;docker-compose.yml&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the documentation, and it's executable.&lt;/p&gt;

&lt;p&gt;The catch — and this is the part to internalize before you start — is that &lt;q&gt;WordPress in Docker locally&lt;/q&gt; and &lt;q&gt;WordPress in Docker in production&lt;/q&gt; are different projects with different tradeoffs. This guide is firmly about the first. We'll cover why you probably shouldn't Dockerize production later on.&lt;/p&gt;




&lt;h2&gt;
  
  
  A working docker-compose.yml for local WordPress
&lt;/h2&gt;

&lt;p&gt;Here's a complete stack: WordPress (PHP + Apache), a MariaDB database, and phpMyAdmin for poking at tables. Create a project folder, drop this in as &lt;code&gt;docker-compose.yml&lt;/code&gt;, and add a &lt;code&gt;.env&lt;/code&gt; file alongside it.&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="c1"&gt;# docker-compose.yml — local WordPress development stack&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&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;mariadb:11.4&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;MARIADB_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_NAME}&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_USER}&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_ROOT_PASSWORD}&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;db_data:/var/lib/mysql&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;3306:3306"&lt;/span&gt;

  &lt;span class="na"&gt;wordpress&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;wordpress:6.7-php8.3-apache&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&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;WORDPRESS_DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db:3306&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_NAME}&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_USER}&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&lt;/span&gt;
      &lt;span class="c1"&gt;# WP_DEBUG on locally, never in production&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DEBUG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&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;wp_core:/var/www/html&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./wp-content/themes/my-theme:/var/www/html/wp-content/themes/my-theme&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./wp-content/plugins/my-plugin:/var/www/html/wp-content/plugins/my-plugin&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;8080:80"&lt;/span&gt;

  &lt;span class="na"&gt;phpmyadmin&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;phpmyadmin:5.2&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&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;PMA_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="na"&gt;UPLOAD_LIMIT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;64M&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;8081:80"&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;db_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;wp_core&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;


&lt;span class="c1"&gt;# .env — kept out of Git via .gitignore&lt;/span&gt;
&lt;span class="s"&gt;DB_NAME=wordpress&lt;/span&gt;
&lt;span class="s"&gt;DB_USER=wp_user&lt;/span&gt;
&lt;span class="s"&gt;DB_PASSWORD=change-me-locally&lt;/span&gt;
&lt;span class="s"&gt;DB_ROOT_PASSWORD=change-me-too&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Start 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;# Pull images and launch in the background&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# Watch the logs until WordPress finishes its first-run setup&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; wordpress

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

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8080&lt;/code&gt; and you'll land on the WordPress installer. phpMyAdmin is at &lt;code&gt;http://localhost:8081&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What each service does
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;db&lt;/code&gt; (MariaDB).&lt;/strong&gt; This is your database. MariaDB is a drop-in replacement for MySQL and is what many managed hosts actually run, so it's a sensible default. The &lt;code&gt;db_data&lt;/code&gt; named volume persists your tables between restarts — without it, &lt;code&gt;docker compose down&lt;/code&gt; would wipe your posts and settings every time. Note the environment variables use the &lt;code&gt;MARIADB_&lt;/code&gt; prefix; if you swap in the &lt;code&gt;mysql:8.4&lt;/code&gt; image instead, they become &lt;code&gt;MYSQL_DATABASE&lt;/code&gt;, &lt;code&gt;MYSQL_USER&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;wordpress&lt;/code&gt; (PHP + Apache).&lt;/strong&gt; The official &lt;code&gt;wordpress&lt;/code&gt; image bundles PHP and Apache with WordPress core already installed. The tag &lt;code&gt;6.7-php8.3-apache&lt;/code&gt; pins all three: WordPress 6.7, PHP 8.3, Apache. Pin these to match production — that's the entire point. &lt;code&gt;WORDPRESS_DB_HOST: db:3306&lt;/code&gt; works because Compose puts every service on a shared network where the service name (&lt;code&gt;db&lt;/code&gt;) resolves as a hostname.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;phpmyadmin&lt;/code&gt;.&lt;/strong&gt; Optional but handy — a browser UI for the database so you can inspect rows, run queries, and export dumps without a MySQL client. Drop this service entirely if you prefer the CLI.&lt;/p&gt;

&lt;h3&gt;
  
  
  The volume strategy that actually matters
&lt;/h3&gt;

&lt;p&gt;Notice we mount &lt;code&gt;wp_core&lt;/code&gt; (a named volume) over the whole of &lt;code&gt;/var/www/html&lt;/code&gt;, but then bind-mount only &lt;em&gt;your&lt;/em&gt; theme and plugin directories from the host on top of it. This is deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WordPress core, and any plugins you install through the admin UI, live inside the named volume — you don't want those cluttering your Git repo.&lt;/li&gt;
&lt;li&gt;Your custom theme and plugin — the code you actually write and version — live on your host machine at &lt;code&gt;./wp-content/themes/my-theme&lt;/code&gt; and &lt;code&gt;./wp-content/plugins/my-plugin&lt;/code&gt;, so your editor sees them directly and changes appear instantly in the browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This split keeps your repository lean: it contains only the code you author, not the tens of thousands of core files.&lt;/p&gt;




&lt;h2&gt;
  
  
  Working with themes and plugins on the mounted volume
&lt;/h2&gt;

&lt;p&gt;Because your theme and plugin folders are bind-mounted, editing them is just editing files on your machine. Save &lt;code&gt;style.css&lt;/code&gt;, refresh the browser, done — no rebuild, no copy step. That instant feedback loop is the day-to-day payoff of this setup.&lt;/p&gt;

&lt;p&gt;Two gotchas bite almost everyone the first week:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File permissions and UID mismatch.&lt;/strong&gt; Inside the container, Apache runs as the &lt;code&gt;www-data&lt;/code&gt; user (UID 33 on Debian-based images). On Linux hosts, files you create as your own user (often UID 1000) can end up owned by a UID the container can't write to, and WordPress's media uploader will throw permission errors. The quickest local fix is to align ownership on the mounted directory:&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;# Give the container's www-data user write access to uploads&lt;/span&gt;
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 33:33 ./wp-content

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

&lt;/div&gt;



&lt;p&gt;On macOS and Windows this rarely bites because Docker Desktop's filesystem sharing translates UIDs for you. On native Linux it's the number-one cause of &lt;q&gt;why can't I upload an image locally.&lt;/q&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database persistence.&lt;/strong&gt; &lt;code&gt;docker compose down&lt;/code&gt; stops and removes containers but keeps named volumes, so your database survives. &lt;code&gt;docker compose down -v&lt;/code&gt; adds &lt;code&gt;-v&lt;/code&gt; and deletes volumes too — that wipes your local database. Learn the difference now, because running the &lt;code&gt;-v&lt;/code&gt; variant by reflex when you just wanted a clean restart is a classic way to lose an afternoon of test content.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running WP-CLI in a container
&lt;/h3&gt;

&lt;p&gt;You don't install WP-CLI on your host — you run it in a throwaway container that shares the WordPress volumes and network. Add this to &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;wpcli&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;wordpress:cli-php8.3&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;wordpress&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;WORDPRESS_DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db:3306&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_NAME}&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_USER}&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&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;wp_core:/var/www/html&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;33:33"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Then run commands with &lt;code&gt;docker compose run --rm&lt;/code&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="c"&gt;# Install WordPress non-interactively&lt;/span&gt;
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; wpcli wp core &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:8080"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Local Dev Site"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--admin_user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--admin_password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--admin_email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;you@example.com

&lt;span class="c"&gt;# Activate your theme and a plugin&lt;/span&gt;
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; wpcli wp theme activate my-theme
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; wpcli wp plugin activate my-plugin

&lt;span class="c"&gt;# Export the database to a file on your host&lt;/span&gt;
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; wpcli wp db &lt;span class="nb"&gt;export&lt;/span&gt; /var/www/html/backup.sql

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

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--rm&lt;/code&gt; flag deletes the CLI container after each command so they don't pile up. Scripting &lt;code&gt;wp core install&lt;/code&gt; this way means a fresh clone can be fully bootstrapped — WordPress installed, theme active, sample content imported — with a single shell script, which is where the &lt;q&gt;one command and you're running&lt;/q&gt; promise actually pays off.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bridge: from local Docker to production
&lt;/h2&gt;

&lt;p&gt;Here's the mental model that keeps this simple. Docker is your &lt;em&gt;local development environment&lt;/em&gt;. Your custom theme and plugin code is what you actually ship. Git is the pipeline between the two.&lt;/p&gt;

&lt;p&gt;Commit the &lt;code&gt;wp-content/themes/my-theme&lt;/code&gt; and &lt;code&gt;wp-content/plugins/my-plugin&lt;/code&gt; directories (plus &lt;code&gt;docker-compose.yml&lt;/code&gt;) to a repository. Do &lt;strong&gt;not&lt;/strong&gt; commit &lt;code&gt;wp-config.php&lt;/code&gt;, your &lt;code&gt;.env&lt;/code&gt; file, WordPress core, or the &lt;code&gt;uploads&lt;/code&gt; directory — those are environment-specific or reproducible, and secrets never belong in Git.&lt;/p&gt;

&lt;p&gt;From there, you don't manually SFTP files to your server ever again. Once your theme and plugin code is in Git, you can &lt;a href="https://dev.to/alex_morgan_da8d0c98b02a1/how-to-automate-wordpress-deployments-with-git-no-more-ftp-obk-temp-slug-3699271"&gt;automate WordPress deployments from Git&lt;/a&gt; so that every push to your main branch copies the changed files to your live WordPress host over SSH. This is the same &lt;a href="https://www.deployhq.com/blog/deployment-automation-a-quick-overview" rel="noopener noreferrer"&gt;deployment automation&lt;/a&gt; pattern any modern web team uses: your local environment and your production release both trace back to the same commit.&lt;/p&gt;

&lt;p&gt;The important boundary: &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; ships your &lt;em&gt;code&lt;/em&gt; to your host — it isn't a WordPress host and it doesn't run your containers in production for you. Your live site can be a VPS, a managed WordPress host, or shared hosting; &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; pushes the theme and plugin files there and runs any build steps you configure. If you're standing up the server side from scratch, our walkthrough on how to &lt;a href="https://dev.to/deployhq/deploying-a-wordpress-application-on-a-vps-a-beginners-guide-11mb"&gt;install WordPress on a VPS&lt;/a&gt; covers the host setup, and since WordPress is PHP under the hood, the general approach to &lt;a href="https://www.deployhq.com/blog/deploying-your-php-site-with-deployhq" rel="noopener noreferrer"&gt;deploy a PHP site&lt;/a&gt; applies directly.&lt;/p&gt;

&lt;p&gt;Ready to wire your repository to your WordPress host? &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Create a free&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; account and connect your Git provider — start on the free plan or a 10-day trial and automate your first deployment.&lt;/p&gt;

&lt;p&gt;If your theme has a build step — compiling Sass, bundling JavaScript, running Composer — put it in a &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipeline&lt;/a&gt; so the compiled assets are generated on DeployHQ's build servers and only the finished files land on production. Releases are applied as &lt;a href="https://www.deployhq.com/features/atomic-deployments" rel="noopener noreferrer"&gt;atomic deployments&lt;/a&gt; (the new version is swapped in as a whole, so visitors never hit a half-updated site), and if a release misbehaves you get &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;one-click rollback&lt;/a&gt; to the previous version.&lt;/p&gt;




&lt;h2&gt;
  
  
  Keeping local Docker and production in parity
&lt;/h2&gt;

&lt;p&gt;The whole exercise is worthless if local and production quietly diverge. A few habits keep them honest:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin the PHP version to production.&lt;/strong&gt; If your host runs PHP 8.2, use &lt;code&gt;wordpress:6.7-php8.2-apache&lt;/code&gt; locally — not the &lt;code&gt;latest&lt;/code&gt; tag, which floats. A plugin that relies on a function deprecated in PHP 8.3 will pass locally on 8.2 and fail on 8.3 (or vice versa). The image tag is your version contract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Match the database engine and major version.&lt;/strong&gt; If production runs MySQL 8, don't develop against MariaDB 11 and assume they're identical — they diverge on JSON functions, default collations, and some SQL modes. Set the local image to whatever your host actually runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never carry &lt;code&gt;WP_DEBUG&lt;/code&gt; or dev credentials into production.&lt;/strong&gt; The &lt;code&gt;WORDPRESS_DEBUG: 1&lt;/code&gt; line above is great locally and dangerous live. Configuration that differs between environments — debug flags, database credentials, API keys, salts — should come from environment variables on each host, never be baked into committed code. Keeping those inputs in sync (and separate) is exactly the discipline behind &lt;a href="https://dev.to/deployhq/keeping-development-staging-and-production-in-sync-with-deployhq-1i10"&gt;keeping development and production in sync&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just Dockerize production too?&lt;/strong&gt; It's tempting to run the same Compose stack live, but self-hosting a containerized WordPress in production means you now own database backups, TLS termination, security patching of the container host, scaling, and uptime — a real operations burden. For the vast majority of WordPress sites, a managed or VPS host plus automated Git deployment of your theme and plugin code delivers the parity benefit without the ops overhead. Keep Docker where it shines — local development — and let a purpose-built host run the live site. If you &lt;em&gt;do&lt;/em&gt; run containers in production, our guide on how to &lt;a href="https://dev.to/deployhq/deploy-docker-containers-to-a-vps-with-deployhq-shell-servers-177-temp-slug-4032538"&gt;deploy Docker containers to a VPS&lt;/a&gt; covers that path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need to commit WordPress core to Git?&lt;/strong&gt; No. Commit only the code you write — your custom theme and plugin directories, plus &lt;code&gt;docker-compose.yml&lt;/code&gt;. WordPress core, &lt;code&gt;wp-config.php&lt;/code&gt;, uploads, and your &lt;code&gt;.env&lt;/code&gt; file should all be gitignored. Core is reproducible from the image; config and uploads are environment-specific.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my media upload failing with a permission error locally?&lt;/strong&gt; On Linux hosts this is almost always a UID mismatch between your host user and the container's &lt;code&gt;www-data&lt;/code&gt; user (UID 33). Run &lt;code&gt;sudo chown -R 33:33 ./wp-content&lt;/code&gt; on the mounted directory. macOS and Windows usually handle this automatically through Docker Desktop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I reset my local database without losing my code?&lt;/strong&gt; Run &lt;code&gt;docker compose down&lt;/code&gt; (keeps volumes) to stop everything, then &lt;code&gt;docker compose up -d&lt;/code&gt; to restart with data intact. Only &lt;code&gt;docker compose down -v&lt;/code&gt; deletes the database volume. Your theme and plugin files live on your host, so they're never affected either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use MySQL instead of MariaDB?&lt;/strong&gt; Yes. Swap the &lt;code&gt;db&lt;/code&gt; image for &lt;code&gt;mysql:8.4&lt;/code&gt; and rename the environment variables from the &lt;code&gt;MARIADB_&lt;/code&gt; prefix to &lt;code&gt;MYSQL_&lt;/code&gt; (&lt;code&gt;MYSQL_DATABASE&lt;/code&gt;, &lt;code&gt;MYSQL_USER&lt;/code&gt;, &lt;code&gt;MYSQL_PASSWORD&lt;/code&gt;, &lt;code&gt;MYSQL_ROOT_PASSWORD&lt;/code&gt;). Match whatever your production host runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I run this same Docker stack in production?&lt;/strong&gt; For most sites, no. Self-hosting containerized WordPress means owning backups, TLS, patching, and scaling. Keep Docker for local development and deploy your theme and plugin code to a managed or VPS host from Git instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Docker turns local WordPress development from a fragile, machine-specific chore into a reproducible environment that mirrors production. Pin your PHP and database versions to match your host, bind-mount only the theme and plugin code you author, and use WP-CLI in a container to script setup. Then let Git — not manual file transfers — carry that code to your live site.&lt;/p&gt;

&lt;p&gt;Ready to automate the last step? Connect your repository and &lt;a href="https://www.deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;deploy from GitHub to server&lt;/a&gt; — configure your host once, and every push ships your theme and plugin changes automatically.&lt;/p&gt;




&lt;p&gt;Questions about deploying WordPress from Git? Email 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&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>tutorials</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>Deploy Docker Containers to a VPS with DeployHQ Shell Servers</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Mon, 20 Jul 2026 13:58:49 +0000</pubDate>
      <link>https://dev.to/deployhq/deploy-docker-containers-to-a-vps-with-deployhq-shell-servers-4hgo</link>
      <guid>https://dev.to/deployhq/deploy-docker-containers-to-a-vps-with-deployhq-shell-servers-4hgo</guid>
      <description>&lt;p&gt;If your application is already containerised, deploying it should be the easy part — but most teams end up wiring the same fragile bash by hand into a GitHub Action or a long-lived CI runner. This guide covers the shorter path: a Docker container deployment workflow on a VPS or dedicated server, driven by a &lt;a href="https://www.deployhq.com/support/servers/adding-a-server/shell-server" rel="noopener noreferrer"&gt;DeployHQ Shell Server&lt;/a&gt;, with a Git push as the only trigger.&lt;/p&gt;

&lt;p&gt;Shell Servers are the right starting point when you control the host. You run &lt;code&gt;docker&lt;/code&gt; on your own box, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; runs the orchestration. If you're deploying to AWS ECS or EKS instead, hand off to the dedicated guide on &lt;a href="https://www.deployhq.com/blog/deploying-applications-to-aws-ecs-eks-using-deployhq-shell-servers" rel="noopener noreferrer"&gt;deploying containerised apps to ECS and EKS&lt;/a&gt; — that workflow uses Custom Actions and ECR, and it covers the parts (task definitions, &lt;code&gt;services-stable&lt;/code&gt; waits, &lt;code&gt;kubectl rollout&lt;/code&gt;) that a Shell Server won't.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Shell Servers fit and when they don't
&lt;/h2&gt;

&lt;p&gt;Shell Servers run your script over SSH against a host you own — a Hetzner, DigitalOcean, OVH, or on-prem box with the Docker daemon installed. Nothing is uploaded; only the commands you write run remotely. That fits four very common shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A single VPS running 1-10 containers behind Caddy, Traefik, or Nginx&lt;/li&gt;
&lt;li&gt;A small Docker Compose stack (app + Postgres + Redis) that needs a coordinated restart&lt;/li&gt;
&lt;li&gt;A self-hosted internal tool that you want updated on every push to &lt;code&gt;main&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A staging server where you want every PR branch to deploy automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; fit ECS, EKS, GKE, Fargate, or any managed orchestrator — those have no shell to SSH into. For those, use &lt;a href="https://www.deployhq.com/support/servers/adding-a-server/custom-action" rel="noopener noreferrer"&gt;Custom Actions&lt;/a&gt;, which spin up a managed container per deploy and run AWS CLI or &lt;code&gt;kubectl&lt;/code&gt; against the cluster API.&lt;/p&gt;

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

&lt;p&gt;Before the first deploy you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://www.deployhq.com/pricing" rel="noopener noreferrer"&gt;DeployHQ account on a plan that includes Shell Servers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A Linux server (Ubuntu 22.04 LTS or 24.04 LTS recommended) reachable over SSH on port 22 (or a port you'll specify)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Engine 24.0+&lt;/strong&gt; installed on that server. The &lt;a href="https://docs.docker.com/engine/install/ubuntu/" rel="noopener noreferrer"&gt;official Docker convenience script&lt;/a&gt; is the fastest path; the distro-packaged &lt;code&gt;docker.io&lt;/code&gt; lags by several releases and is missing BuildKit defaults&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose v2&lt;/strong&gt; if you're running multi-container stacks. It's bundled as &lt;code&gt;docker compose&lt;/code&gt; (no hyphen) with modern Docker Engine — verify with &lt;code&gt;docker compose version&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The deploy user added to the &lt;code&gt;docker&lt;/code&gt; group (&lt;code&gt;sudo usermod -aG docker deploy &amp;amp;&amp;amp; newgrp docker&lt;/code&gt;) so it can run &lt;code&gt;docker&lt;/code&gt; without &lt;code&gt;sudo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Read/write access to your container registry. For Docker Hub: a &lt;a href="https://docs.docker.com/security/for-developers/access-tokens/" rel="noopener noreferrer"&gt;Personal Access Token&lt;/a&gt;, not your account password. For AWS ECR: an IAM user with &lt;code&gt;ecr:GetAuthorizationToken&lt;/code&gt;, &lt;code&gt;ecr:BatchGetImage&lt;/code&gt;, and &lt;code&gt;ecr:PutImage&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Your Git repository connected to &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; — both &lt;a href="https://www.deployhq.com/deploy-from-github" rel="noopener noreferrer"&gt;deploy from GitHub&lt;/a&gt; and &lt;a href="https://www.deployhq.com/deploy-from-gitlab" rel="noopener noreferrer"&gt;deploy from GitLab&lt;/a&gt; work the same way&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://www.deployhq.com/cheatsheets/docker" rel="noopener noreferrer"&gt;Docker cheatsheet&lt;/a&gt; covers the host-side commands referenced throughout this guide — &lt;code&gt;docker system prune&lt;/code&gt;, &lt;code&gt;docker image ls --filter&lt;/code&gt;, &lt;code&gt;docker logs --since&lt;/code&gt;, and the diagnostic flags you'll want when a deploy goes sideways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Add the Shell Server to DeployHQ
&lt;/h2&gt;

&lt;p&gt;Inside your project, go to &lt;strong&gt;Servers &amp;amp; Groups &amp;gt; New Server&lt;/strong&gt; and pick &lt;strong&gt;Shell&lt;/strong&gt;. The fields that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hostname&lt;/strong&gt; : IP or DNS name of the box&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Username&lt;/strong&gt; : the user with &lt;code&gt;docker&lt;/code&gt; group membership&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port&lt;/strong&gt; : 22 unless you've moved it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH key&lt;/strong&gt; : paste your project's public key into the server's &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;. Password auth works but you'll regret it the first time you rotate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Working directory&lt;/strong&gt; : where your repo will be checked out for the deploy. &lt;code&gt;/srv/myapp&lt;/code&gt; is a reasonable convention&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdv6ei6ddi31uc9jhsmnj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdv6ei6ddi31uc9jhsmnj.png" alt="Add Shell Server in DeployHQ" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Smoke-test the connection with &lt;strong&gt;Test Connection&lt;/strong&gt; before continuing. If it fails, 90% of the time it's &lt;code&gt;Permission denied (publickey)&lt;/code&gt; because the key isn't in &lt;code&gt;authorized_keys&lt;/code&gt;, or the wrong user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Write a Dockerfile that's actually deployable
&lt;/h2&gt;

&lt;p&gt;Most Docker container deployment problems are baked in at the Dockerfile stage. The minimal Dockerfile you find on Docker's getting-started page works for &lt;code&gt;docker run&lt;/code&gt; on your laptop, but it has three problems that show up on the second deploy.&lt;/p&gt;

&lt;p&gt;Here's a production-leaning Dockerfile for a Python Flask app. The patterns are the same for Node, Ruby, Go, or Java — only the base image and dependency-install commands change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# syntax=docker/dockerfile:1.7&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.12-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;

&lt;span class="c"&gt;# Don't run as root in the container&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;useradd &lt;span class="nt"&gt;--create-home&lt;/span&gt; &lt;span class="nt"&gt;--uid&lt;/span&gt; 1000 app

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# Install deps separately from app code so the layer caches across deploys&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="c"&gt;# Now copy application code — invalidates only when code changes&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=app:app . .&lt;/span&gt;

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; app&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 5000&lt;/span&gt;

&lt;span class="c"&gt;# tini handles SIGTERM properly; without it `docker stop` SIGKILLs after 10s mid-request&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/usr/bin/tini", "--"]&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["python", "app.py"]&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The three fixes most starter Dockerfiles get wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;COPY requirements.txt&lt;/code&gt; before &lt;code&gt;COPY . .&lt;/code&gt;&lt;/strong&gt; — keeps the dependency layer cached. Without it, &lt;code&gt;pip install&lt;/code&gt; re-runs on every code change, turning 10-second builds into 90-second ones&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A non-root &lt;code&gt;app&lt;/code&gt; user with &lt;code&gt;USER app&lt;/code&gt;&lt;/strong&gt; — if your app process is compromised, the blast radius is the container's filesystem, not root in the container&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tini&lt;/code&gt; as the entrypoint&lt;/strong&gt; — Python and Node don't forward &lt;code&gt;SIGTERM&lt;/code&gt; to child processes, so &lt;code&gt;docker stop&lt;/code&gt; waits 10 seconds then &lt;code&gt;SIGKILL&lt;/code&gt;s mid-request. &lt;code&gt;tini&lt;/code&gt; is a 220KB init that reaps zombies and forwards signals correctly&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full pattern for Node, including multi-stage builds and the &lt;code&gt;.dockerignore&lt;/code&gt; that goes with it, is in the &lt;a href="https://dev.to/deployhq/dockerize-a-nodejs-app-a-production-ready-walkthrough-3ik8-temp-slug-9588855"&gt;production-ready Node.js Dockerfile walkthrough&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Build and test locally before pushing — if it doesn't run on your laptop, the registry push is wasted work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;docker build -t myapp:dev .
docker run --rm -p 5000:5000 myapp:dev

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Push to a container registry
&lt;/h2&gt;

&lt;p&gt;Your Shell Server pulls the image; it doesn't build it. That means the build either happens (a) on a &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipeline&lt;/a&gt; before the Shell Server step runs, or (b) on the Shell Server itself if it has spare CPU and RAM. Build pipelines are the cleaner pattern because they decouple build from deploy and surface failures earlier — see &lt;a href="https://www.deployhq.com/blog/what-is-a-build-pipeline" rel="noopener noreferrer"&gt;what a build pipeline actually does&lt;/a&gt; for the model.&lt;/p&gt;

&lt;p&gt;Tag with the Git commit SHA — never with &lt;code&gt;:latest&lt;/code&gt; alone:&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 a DeployHQ build pipeline, ${REVISION} is the commit SHA&lt;/span&gt;
&lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myorg/myapp:&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REVISION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

docker login &lt;span class="nt"&gt;--username&lt;/span&gt; myorg &lt;span class="nt"&gt;--password-stdin&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DOCKERHUB_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$IMAGE&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; myorg/myapp:latest &lt;span class="nb"&gt;.&lt;/span&gt;
docker push &lt;span class="nv"&gt;$IMAGE&lt;/span&gt;
docker push myorg/myapp:latest

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

&lt;/div&gt;



&lt;p&gt;Why both tags? &lt;code&gt;:${REVISION}&lt;/code&gt; is what you pull on the Shell Server (immutable, traceable, rollback-friendly). &lt;code&gt;:latest&lt;/code&gt; is what your local &lt;code&gt;docker compose pull&lt;/code&gt; grabs for development. If you only push &lt;code&gt;:latest&lt;/code&gt;, you've thrown away your rollback target — a 30-second incident becomes a 30-minute one.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;DOCKERHUB_TOKEN&lt;/code&gt;, &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt;, and any other secrets as &lt;strong&gt;environment variables&lt;/strong&gt; in &lt;strong&gt;Project Settings &amp;gt; Environment Variables&lt;/strong&gt; , marked as protected so they don't leak into logs. If you need encrypted-at-rest secrets you can commit alongside your repo, the &lt;a href="https://dev.to/deployhq/how-to-deploy-php-applications-with-encrypted-environment-variables-using-dotenvx-and-deployhq-4bp3"&gt;Dotenvx encrypted env workflow&lt;/a&gt; handles that without a second secrets store. For AWS ECR registries, the login is a one-liner — see the &lt;a href="https://www.deployhq.com/cheatsheets/aws-cli" rel="noopener noreferrer"&gt;AWS CLI cheatsheet&lt;/a&gt; for the exact &lt;code&gt;aws ecr get-login-password&lt;/code&gt; invocation and the surrounding session patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The Shell Server deploy script
&lt;/h2&gt;

&lt;p&gt;This is the only script you actually maintain. It runs on the target host as the deploy user, with the repo &lt;em&gt;not&lt;/em&gt; uploaded — the Shell Server runs commands, it doesn't transfer files. Anything you need from the repo (a &lt;code&gt;docker-compose.yml&lt;/code&gt;, Kubernetes manifests, migration scripts) either lives on the box already, gets fetched by the script, or comes from your registry inside the image.&lt;/p&gt;

&lt;p&gt;For a single-container app:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"myorg/myapp:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REVISION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Pulling &lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
docker pull &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Stopping previous container (if any)..."&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

echo&lt;/span&gt; &lt;span class="s2"&gt;"Starting new container..."&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 5000:5000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env-file&lt;/span&gt; /etc/myapp/.env &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Waiting for health check..."&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;1..30&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if &lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; http://localhost:5000/healthz &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Healthy after &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;
    docker image prune &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;--filter&lt;/span&gt; &lt;span class="s2"&gt;"until=72h"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;1
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Health check failed — rolling back"&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 5000:5000 &lt;span class="nt"&gt;--env-file&lt;/span&gt; /etc/myapp/.env &lt;span class="s2"&gt;"myorg/myapp:previous"&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;1

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

&lt;/div&gt;



&lt;p&gt;Two details to call out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The 30-second health-check loop turns this from &lt;q&gt;did the container start&lt;/q&gt; (always true) into &lt;q&gt;is the new revision serving traffic.&lt;/q&gt; Without it &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; will report success even when the app is crash-looping. The loop is the same shape as the &lt;code&gt;services-stable&lt;/code&gt; wait that ECS gives you for free&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker image prune --filter "until=72h"&lt;/code&gt; keeps the disk from filling up with old image layers. Three days is enough to roll back to anything from the last week's deploys; tighten or loosen as your disk allows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a Compose stack, the deploy is &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d --remove-orphans&lt;/code&gt; — Compose handles the container churn and respects &lt;code&gt;depends_on&lt;/code&gt; and health checks defined in the YAML. Keep the YAML in &lt;code&gt;/srv/myapp/docker-compose.yml&lt;/code&gt; on the server, version-controlled separately or synced via a build pipeline.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Start the deploy script with &lt;code&gt;set -euo pipefail&lt;/code&gt;.&lt;/strong&gt; Without it, a failed &lt;code&gt;docker pull&lt;/code&gt; (image not found, registry down) returns non-zero, your script keeps going, and the next &lt;code&gt;docker run&lt;/code&gt; happily starts the &lt;em&gt;previous&lt;/em&gt; image — silent rollback to yesterday's bug. &lt;code&gt;set -e&lt;/code&gt; makes the script halt on the first failure; &lt;code&gt;-u&lt;/code&gt; catches undefined variables; &lt;code&gt;-o pipefail&lt;/code&gt; propagates exit codes through pipes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 5: Automate on every push
&lt;/h2&gt;

&lt;p&gt;The whole point of this setup is that you don't run anything manually after the first deploy. In &lt;strong&gt;Project Settings &amp;gt; Automatic Deployments&lt;/strong&gt; , enable deployment for the branch (&lt;code&gt;main&lt;/code&gt; for production, &lt;code&gt;staging&lt;/code&gt; for staging) and copy the webhook URL into your GitHub or GitLab repository's &lt;strong&gt;Settings &amp;gt; Webhooks&lt;/strong&gt;. The &lt;a href="https://www.deployhq.com/support/deployments/automatic-deployments/github-webhook" rel="noopener noreferrer"&gt;GitHub webhook setup guide&lt;/a&gt; walks through the exact steps; GitLab is identical in shape.&lt;/p&gt;

&lt;p&gt;From here, the full loop is: &lt;code&gt;git push&lt;/code&gt; → &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; runs the build pipeline (image build + push to registry) → &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; runs the Shell Server script (pull + restart + health check) → success or &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;one-click rollback&lt;/a&gt; on the dashboard if the health check failed.&lt;/p&gt;

&lt;p&gt;Need to verify your project's deployment safety story before going live? &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Sign up for DeployHQ&lt;/a&gt; and run the first deploy against a staging server — the free tier includes one project and unlimited deploys, which is enough to wire up the full pipeline before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production gotchas this workflow doesn't solve
&lt;/h2&gt;

&lt;p&gt;Shell Server deployments are good but they aren't free of trade-offs. Three you'll hit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No automatic blue/green.&lt;/strong&gt; The script above stops the old container and starts the new one, with a few seconds of 502 in between unless your reverse proxy (Caddy, Traefik, Nginx) has retry-on-fail configured. For real zero-downtime on a single host, run two container versions side by side behind the proxy and flip the upstream — covered in the &lt;a href="https://www.deployhq.com/features/zero-downtime-deployments" rel="noopener noreferrer"&gt;zero-downtime deployments&lt;/a&gt; feature page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No horizontal scale.&lt;/strong&gt; One host, one running version. For multi-host orchestration, ECS/EKS or a managed Kubernetes is the right next step&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State management is on you.&lt;/strong&gt; Volumes, named volumes vs bind mounts, Postgres data outside the container — Docker doesn't help you here. The deploy script must never destroy a volume on rollback. Use &lt;code&gt;docker volume ls&lt;/code&gt; to audit what's persistent before automating&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want a worked Compose example end-to-end on Ubuntu, the &lt;a href="https://www.deployhq.com/blog/how-to-deploy-metabase-on-ubuntu-20-04-with-docker-and-deployhq" rel="noopener noreferrer"&gt;Metabase Docker Compose walkthrough&lt;/a&gt; covers the YAML, the systemd unit, and the same Shell Server deploy script applied to a multi-container stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Docker container deployment with a &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Shell Server replaces three things at once: the GitHub Action that's almost-but-not-quite right, the EC2 runner that nobody patches, and the bash one-liner in someone's terminal that nobody else can reproduce. You write one script, the Shell Server runs it on every push, the build pipeline keeps your image immutable per commit, and rollback is a single click. That's the workflow.&lt;/p&gt;

&lt;p&gt;For the Docker primer if any of this felt like a fast skim, see &lt;a href="https://www.deployhq.com/blog/what-is-docker" rel="noopener noreferrer"&gt;what Docker is and how containers, images, and registries fit together&lt;/a&gt;. For the orchestrator-side command reference when you graduate to Kubernetes, the &lt;a href="https://www.deployhq.com/cheatsheets/kubectl" rel="noopener noreferrer"&gt;kubectl cheatsheet&lt;/a&gt; is the page to bookmark.&lt;/p&gt;




&lt;p&gt;Questions or running into a deploy that won't behave? 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;X (Twitter)&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What's the difference between a DeployHQ Shell Server and a Custom Action for Docker deployments?
&lt;/h3&gt;

&lt;p&gt;A Shell Server runs your script over SSH on a host you own — your Hetzner box, your bare-metal server, your home lab. A Custom Action runs in a managed container that &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; spins up per deploy, with no persistent host to maintain — that's what you use for ECS, EKS, or anywhere there's no SSH-able machine. Shell Servers fit single-VPS and small Compose stacks; Custom Actions fit cloud-native orchestrators.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why tag images with the commit SHA instead of &lt;code&gt;:latest&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Because rollback is then a single &lt;code&gt;docker pull myorg/myapp:&amp;lt;previous-sha&amp;gt; &amp;amp;&amp;amp; docker run ...&lt;/code&gt; against a specific, named image. With &lt;code&gt;:latest&lt;/code&gt;-only, the previous version has been overwritten in the registry and there's nothing to roll back to. Tagging with &lt;code&gt;${REVISION}&lt;/code&gt; (the commit SHA) gives you an immutable per-deploy artefact that pairs with the rollback button on the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a build pipeline, or can the Shell Server build the image too?
&lt;/h3&gt;

&lt;p&gt;Either works. The Shell Server can run &lt;code&gt;docker build&lt;/code&gt; and &lt;code&gt;docker push&lt;/code&gt; if it has spare CPU and RAM. A build pipeline is cleaner because it decouples build from deploy — the build runs on DeployHQ's infrastructure, the deploy runs on your server, and a build failure never touches the production host. For production setups, prefer the build pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I keep registry credentials out of my repo?
&lt;/h3&gt;

&lt;p&gt;Store them as &lt;strong&gt;Environment Variables&lt;/strong&gt; under &lt;strong&gt;Project Settings&lt;/strong&gt; , marked as protected. They get injected into the build pipeline and Shell Server session at deploy time. Never commit &lt;code&gt;.env&lt;/code&gt; files, Docker config JSON, or AWS credentials into the repo — even private repositories leak via forks, mistakes, and &lt;code&gt;git log --all&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I deploy Docker Compose stacks the same way?
&lt;/h3&gt;

&lt;p&gt;Yes — replace the &lt;code&gt;docker run&lt;/code&gt; step in Step 4 with &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d --remove-orphans&lt;/code&gt;. Keep the &lt;code&gt;docker-compose.yml&lt;/code&gt; on the server (in the working directory you configured for the Shell Server) and let the script trigger Compose to reconcile. The same health-check loop applies — wait for the new containers to be healthy before reporting success.&lt;/p&gt;

</description>
      <category>devopsinfrastructure</category>
      <category>docker</category>
      <category>tipstricks</category>
      <category>tutorials</category>
    </item>
    <item>
      <title>DeployHQ Managed VPS vs Render: Real Linux vs PaaS Sandbox</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Mon, 13 Jul 2026 08:49:34 +0000</pubDate>
      <link>https://dev.to/deployhq/deployhq-managed-vps-vs-render-real-linux-vs-paas-sandbox-487e</link>
      <guid>https://dev.to/deployhq/deployhq-managed-vps-vs-render-real-linux-vs-paas-sandbox-487e</guid>
      <description>&lt;p&gt;&lt;a href="https://www.deployhq.com/hosting/managed-vps" rel="noopener noreferrer"&gt;DeployHQ Managed VPS&lt;/a&gt; and Render solve the same problem — get an app from a Git repository to a running production service — but they take opposite architectural bets. Render is fully managed PaaS: containers, autoscaling, managed databases, zero infrastructure decisions. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS is a real Linux box with full SSH access plus the deployment pipeline wired in.&lt;/p&gt;

&lt;p&gt;The right choice depends on whether your workload needs real Linux (custom services, SSH access, anything outside a PaaS sandbox) or whether the PaaS abstraction's constraints are actually freedoms — fewer decisions to make, less operational surface to maintain.&lt;/p&gt;

&lt;p&gt;This guide is a head-to-head comparison: features, pricing, lock-in, and when each one wins. (For broader Heroku-replacement framing — Render alongside Railway and Fly.io — our &lt;a href="https://dev.to/deployhq/heroku-alternatives-in-2026-deployhq-your-vps-vs-render-vs-railway-vs-flyio-347b-temp-slug-7668051"&gt;Heroku alternatives in 2026 roundup&lt;/a&gt; covers the wider migration story.)&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;If your application fits a PaaS shape — stateless web service, managed Postgres, autoscaling on CPU thresholds, no need to SSH into the box — Render is the cleaner answer. The PaaS model removes a meaningful amount of operational work, and Render's product surface is mature and well-designed.&lt;/p&gt;

&lt;p&gt;If you need a real Linux box — for SSH access, custom services, cron jobs, third-party agents (monitoring, log shipping, backups), or any workload that needs to step outside a sandbox — &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS is the right product. You get a real Ubuntu VPS plus the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deployment pipeline already wrapped around it.&lt;/p&gt;

&lt;p&gt;For broader context, our &lt;a href="https://dev.to/deployhq/best-software-deployment-tools-in-2026-3g9o"&gt;roundup of the best software deployment tools in 2026&lt;/a&gt; places Render, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, and the rest of the category side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  At a glance: feature comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;DeployHQ Managed VPS&lt;/th&gt;
&lt;th&gt;Render&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Architectural model&lt;/td&gt;
&lt;td&gt;Linux VPS + deployment pipeline&lt;/td&gt;
&lt;td&gt;PaaS containers + managed services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root SSH access&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (PaaS sandbox)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom services / cron / daemons&lt;/td&gt;
&lt;td&gt;Yes (it's a real Linux box)&lt;/td&gt;
&lt;td&gt;Cron jobs supported; custom binaries limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Underlying infrastructure&lt;/td&gt;
&lt;td&gt;DigitalOcean&lt;/td&gt;
&lt;td&gt;AWS / GCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server sizes&lt;/td&gt;
&lt;td&gt;1-2 vCPU / 1-4 GB (beta)&lt;/td&gt;
&lt;td&gt;Container instances (Starter, Standard, Pro tiers)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autoscaling&lt;/td&gt;
&lt;td&gt;No (manual sizing)&lt;/td&gt;
&lt;td&gt;Yes (CPU / memory thresholds)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed databases bundled&lt;/td&gt;
&lt;td&gt;No (run your own or use external)&lt;/td&gt;
&lt;td&gt;Yes (managed Postgres, Redis)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build pipeline&lt;/td&gt;
&lt;td&gt;DeployHQ generic pipeline&lt;/td&gt;
&lt;td&gt;Render native build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy other targets in same project&lt;/td&gt;
&lt;td&gt;Yes (VPS, static, shared, S3)&lt;/td&gt;
&lt;td&gt;No (Render-only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One bill with deployment tool&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (Render bundles both)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beta status&lt;/td&gt;
&lt;td&gt;Yes (mid-2026)&lt;/td&gt;
&lt;td&gt;GA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor lock-in&lt;/td&gt;
&lt;td&gt;Low (it's just Linux + SSH)&lt;/td&gt;
&lt;td&gt;Medium-high (PaaS runtime + service bindings)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Real Linux on one side. PaaS sandbox on the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  When DeployHQ Managed VPS is the right choice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You need SSH access to a real Linux box.&lt;/strong&gt; This is the structural differentiator and it's where most &lt;q&gt;I can't use Render for this&lt;/q&gt; decisions land. If your app needs to install system packages, run a background worker outside Render's supported types, set up a custom cron schedule with unusual semantics, run a third-party monitoring or log-shipping agent, or debug live with &lt;code&gt;strace&lt;/code&gt; / &lt;code&gt;tcpdump&lt;/code&gt; / &lt;code&gt;journalctl&lt;/code&gt;, PaaS locks you out. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS gives you root over SSH and you do whatever Linux lets you do. The &lt;a href="https://dev.to/deployhq/managed-vps-hosting-on-deployhq-linux-servers-with-built-in-deployments-3d7o-temp-slug-8068925"&gt;Managed VPS pillar guide&lt;/a&gt; walks through what you get out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You want a generic deployment pipeline that ships to many targets.&lt;/strong&gt; Render's build-and-deploy is specific to Render — it builds, runs, and serves your code on Render. DeployHQ's pipeline ships any artifact to any target — Managed VPS, your own Hetzner box, &lt;a href="https://www.deployhq.com/guides/digitalocean" rel="noopener noreferrer"&gt;DigitalOcean Droplets&lt;/a&gt;, shared hosting, S3 buckets, &lt;a href="https://www.deployhq.com/hosting/static" rel="noopener noreferrer"&gt;DeployHQ Static Hosting&lt;/a&gt;. If your project has any non-PaaS-shaped component (a worker that needs root, a static frontend you want on a separate edge, a service that has to live on shared hosting because compliance), &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; handles all of it. Render handles the Render-shaped part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You don't want PaaS lock-in on your runtime.&lt;/strong&gt; Render's value-adds — managed Postgres bound to the app, autoscaling on dedicated tiers, the Render runtime conventions — are tightly coupled to staying on Render. If you outgrow Render's constraints or pricing, migration means re-architecting around a different runtime. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS runs stock Linux — your app already works anywhere Linux runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You want to mix targets in one project.&lt;/strong&gt; A real example: production web on Managed VPS, background jobs on a separate Managed VPS or BYO VPS, static frontend on Static Hosting, all in one &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; project with one billing relationship. Render can't host the static frontend at scale (Render Static Sites exists but lives in the Render ecosystem) and can't deploy to non-Render targets. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; handles all of it from one project.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Render is the right choice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You don't want to manage Linux at all.&lt;/strong&gt; Render is genuine PaaS — no OS to patch, no security updates to apply, no system packages to manage. If &lt;q&gt;I don't want to think about the box, I just want to push code&lt;/q&gt; describes your team, Render delivers that cleanly. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS is &lt;em&gt;less&lt;/em&gt; operational work than a raw VPS, but it's still a Linux box; if you'd rather not have that surface, Render's abstraction is what you want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need autoscaling.&lt;/strong&gt; Render's dedicated tiers offer autoscaling on CPU and memory thresholds. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS is manually sized — pick a tier, resize as needed. If your workload spikes unpredictably and you want the platform to absorb it without intervention, Render is the right answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You want managed databases bundled in.&lt;/strong&gt; Render offers managed Postgres and Redis with private networking, automatic backups, point-in-time recovery, and bindings into your app. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS doesn't bundle a database — you'd run Postgres yourself on the VPS (fine for many workloads), use a separate managed service (Neon, Supabase, DigitalOcean Managed Databases), or self-host in a separate VPS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your workload genuinely fits the PaaS shape.&lt;/strong&gt; Stateless web service, reads from a managed database, writes to object storage — the canonical PaaS shape. If that describes your app and you don't need to step outside it, Render's constraints aren't really constraints. The simplicity is a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing: side by side
&lt;/h2&gt;

&lt;p&gt;As of June 2026 — check vendor pages for current numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Render&lt;/strong&gt; offers a Free tier (with limitations: services sleep after idle, build minute caps, single concurrent build), then paid web service tiers starting from a Starter price point and scaling up to dedicated instances with autoscaling. Managed Postgres starts free with row limits and scales up with paid tiers offering increased storage and high availability. The current exact tier pricing is best checked directly on render.com — Render adjusts pricing periodically and a stale number here is worse than no number.&lt;/p&gt;

&lt;p&gt;What's worth noting structurally: Render's pricing scales with usage in a few dimensions — instance size, instance count (for autoscaling), database tier, bandwidth, and additional service slots. For a single small web service plus a small database, Render is competitive with other PaaS options. For larger workloads with several services and a HA database, the bill can scale quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DeployHQ Managed VPS&lt;/strong&gt; charges a fixed monthly rate per server, shown on the provisioning form in your account's currency. Server sizes are currently 1 vCPU / 1 GB up to 2 vCPU / 4 GB in the beta. Pricing rolls into your &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; plan on a single monthly invoice. Trial accounts can provision one Managed VPS at the smallest size at no charge.&lt;/p&gt;

&lt;p&gt;The honest framing: a single small Render web service plus a small managed DB can be cheaper than &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS plus a separately hosted Postgres if you only have one app. The math tilts toward &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; when you have multiple deployment targets (one project deploying to multiple servers) or you're already paying for &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; to deploy code elsewhere. &lt;a href="https://www.deployhq.com/pricing" rel="noopener noreferrer"&gt;Compare current&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; pricing for the full breakdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving between them (it's not all-or-nothing)
&lt;/h2&gt;

&lt;p&gt;Different architectural models means migration involves real work in either direction — not just DNS swaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From Render to &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS&lt;/strong&gt;: provision the VPS in &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, install your app's runtime (Node, Ruby, Python, Go — whatever Render was running), set up your service supervisor (systemd, PM2, Passenger), migrate your managed Postgres data to either a self-hosted Postgres on the same VPS or a separate managed-DB provider, and configure DeployHQ's pipeline to handle build + deploy. If Render was handling autoscaling for you, you'll either manually size the VPS for peak load or move to a different scaling model (load balancer + multiple VPS instances). The &lt;a href="https://dev.to/deployhq/migrating-to-deployhq-a-smooth-transition-guide-151g"&gt;migrating to&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; guide covers the broader migration pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS to Render&lt;/strong&gt;: re-architect around Render's runtime conventions. Define your &lt;code&gt;render.yaml&lt;/code&gt; service spec, set up managed-DB bindings, adapt any custom services to Render-supported types. If your app used SSH-only operations (cron jobs with unusual semantics, custom binary execution, monitoring agents), find PaaS-compatible alternatives. This is harder in practice than the other direction because you're moving toward a more constrained runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or: combine them.&lt;/strong&gt; Run web tier on Render for autoscaling and managed DB, run worker processes or background jobs on a &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS where you can install whatever you need, and keep both in one &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; project for unified deployment of the worker side. This combines the autoscaling story (Render) with the real-Linux story (DeployHQ Managed VPS) — both architectures get used for what they're good at.&lt;/p&gt;

&lt;p&gt;If you're stepping up from shared hosting and weighing modern deployment options generally, &lt;a href="https://dev.to/deployhq/5-signs-its-time-to-upgrade-from-shared-hosting-to-automated-deployments-2e8o"&gt;5 signs it's time to upgrade from shared hosting to automated deployments&lt;/a&gt; covers the inflection points.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;q&gt;real Linux vs PaaS&lt;/q&gt; trade-off
&lt;/h2&gt;

&lt;p&gt;Both products solve the deployment problem. They solve it for different application shapes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; is excellent for canonical PaaS workloads — stateless web services, standard runtimes, managed databases. It removes operational work in exchange for runtime constraints. Most CRUD apps fit this shape comfortably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeployHQ Managed VPS&lt;/strong&gt; is excellent when the PaaS shape doesn't fit — when you need root access, custom services, third-party agents, or any workload that needs to step outside a sandbox. It's a real Linux box with deployment automation wrapped around it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest answer for many teams is &lt;q&gt;both.&lt;/q&gt; Run the canonical web tier on Render for the autoscaling and managed-DB ergonomics. Run the worker/cron/agent side on a Managed VPS where you can do whatever Linux lets you do. Use &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; as the unifying deployment surface for whatever you put on the VPS side. The application splits along architectural lines instead of along vendor lines.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://www.deployhq.com/guides/express" rel="noopener noreferrer"&gt;Express / Node service deployments&lt;/a&gt; specifically, both platforms handle Node well — the choice comes down to whether you want the runtime managed for you or you want SSH into the box.&lt;/p&gt;

&lt;p&gt;For the wider catalog of where Managed VPS fits with other &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; hosting options, the &lt;a href="https://www.deployhq.com/hosting" rel="noopener noreferrer"&gt;hosting hub&lt;/a&gt; covers all five hosting types.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS allow SSH access like a regular VPS?&lt;/strong&gt;Yes. It's a real Linux VPS. SSH in with the key &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; generates (or your own key), install what you want, run any service. DeployHQ's deployment pipeline handles the build-and-deploy automation but doesn't restrict what runs on the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Render allow SSH access?&lt;/strong&gt; No. Render is PaaS — you interact with the platform through its dashboard, CLI, and API. SSH into a running container isn't supported. This is by design — it's what makes the PaaS abstraction work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run a &lt;a href="https://www.deployhq.com/guides/laravel" rel="noopener noreferrer"&gt;Laravel deployment&lt;/a&gt; on &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS?&lt;/strong&gt;Yes. PHP-FPM, Nginx, Composer, queue workers via Supervisor, Laravel scheduler via cron — all standard Linux setup. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; handles the build (Composer install, migrations, asset compilation), the VPS handles the runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about managed Postgres on &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS?&lt;/strong&gt;Not bundled. For small workloads, install Postgres on the VPS itself. For HA / point-in-time recovery / managed backups, point at a separate managed-DB service. DigitalOcean Managed Databases pairs well since the VPS is already on DO.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a free tier on &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS?&lt;/strong&gt;Trial accounts can provision one Managed VPS at the smallest size at no charge. Custom SSH keys aren't available on trial; &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; generates and uses a managed key automatically. Render has a free web service tier (with idle-sleep limitations) and free DB tier — both are useful for evaluation but typically not for production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get started
&lt;/h2&gt;

&lt;p&gt;If you're already on &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt;, enable beta features under &lt;strong&gt;Settings &amp;gt; Beta Features&lt;/strong&gt; and add a Managed VPS server to any project. The &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;one-click rollback feature&lt;/a&gt; works on Managed VPS the same way it works on a BYO server.&lt;/p&gt;

&lt;p&gt;If you're new, &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;start a free trial&lt;/a&gt; and the included Managed VPS lets you ship a real project end-to-end before committing. For the wider picture of how Managed VPS fits with the other hosting options &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; supports, see &lt;a href="https://www.deployhq.com/blog/deployhq-your-universal-deployment-platform-for-all-hosting-types" rel="noopener noreferrer"&gt;your universal deployment and hosting platform&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Render and &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS are both good products in their lanes. Render wins when your workload fits PaaS. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; Managed VPS wins when it doesn't — or when you want real Linux for any reason. The split is structural, not feature-level; pick the architecture, not the feature list.&lt;/p&gt;

&lt;p&gt;For more detail on the Managed VPS product, the &lt;a href="https://www.deployhq.com/support/servers/managed-vps-hosting" rel="noopener noreferrer"&gt;Managed VPS support library&lt;/a&gt; has the full provisioning, lifecycle, and management documentation.&lt;/p&gt;




&lt;p&gt;Questions or feedback on Managed VPS vs Render? Email &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or follow &lt;a href="https://x.com/deployhq" rel="noopener noreferrer"&gt;@deployhq&lt;/a&gt; on X for product updates.&lt;/p&gt;

</description>
      <category>devopsinfrastructure</category>
      <category>tutorials</category>
      <category>vps</category>
    </item>
    <item>
      <title>Using Claude Code with SQLite: The Safest Way to Prototype Queries Locally</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Mon, 13 Jul 2026 08:49:24 +0000</pubDate>
      <link>https://dev.to/deployhq/using-claude-code-with-sqlite-the-safest-way-to-prototype-queries-locally-5ap3</link>
      <guid>https://dev.to/deployhq/using-claude-code-with-sqlite-the-safest-way-to-prototype-queries-locally-5ap3</guid>
      <description>&lt;p&gt;Most guides about connecting an AI coding assistant to a database start with the hard case: a production Postgres cluster, credentials, network access, read-only roles. That is exactly backwards. If you are just getting comfortable with letting an assistant explore a schema and draft SQL for you, the smartest place to start is the database that lives entirely inside a single file on your laptop.&lt;/p&gt;

&lt;p&gt;SQLite is that database. There is no server to run, no port to open, no user to grant. The whole thing is one &lt;code&gt;.sqlite&lt;/code&gt; file you can open, copy, delete, and restore in seconds. That makes it the ideal sandbox for AI-assisted database work — a place where a bad query or a botched schema change costs you nothing, because rolling back is as simple as copying a file back over another one.&lt;/p&gt;

&lt;p&gt;This post walks through pointing &lt;a href="https://dev.to/deployhq/getting-started-with-claude-code-the-ai-coding-assistant-for-your-terminal-4cba"&gt;Claude Code&lt;/a&gt; at a local SQLite file, why a file-based database is the safest experimentation surface you can give an AI assistant, and how to prototype real queries before promoting anything to a server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why SQLite is the right place to start
&lt;/h2&gt;

&lt;p&gt;Every other database engine assumes infrastructure. PostgreSQL and MySQL run as long-lived server processes, listen on a network port, and gate access behind users and roles. That is the correct model for production — but it is a lot of moving parts to stand up just to experiment with an AI assistant.&lt;/p&gt;

&lt;p&gt;SQLite removes all of it. A SQLite database is a single ordinary file. Your application (or your assistant's tooling) opens the file directly and reads and writes rows in place. There is no daemon, no &lt;code&gt;localhost:5432&lt;/code&gt;, no authentication handshake. If you have ever seen a &lt;code&gt;.db&lt;/code&gt; or &lt;code&gt;.sqlite&lt;/code&gt; file in a project, you have already been using it.&lt;/p&gt;

&lt;p&gt;For AI-assisted work, three properties matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is trivially reproducible.&lt;/strong&gt; Delete the file, run your seed script, and you are back to a known state. No &lt;code&gt;DROP DATABASE&lt;/code&gt;, no re-provisioning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is completely local.&lt;/strong&gt; Nothing leaves your machine. There is no risk of an assistant accidentally touching shared staging data because there is no shared anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is disposable by design.&lt;/strong&gt; A SQLite file is meant to be copied, moved, and thrown away. That disposability is what turns it into a safe playground.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the deeper trade-offs between the engines, the &lt;a href="https://dev.to/deployhq/sqlite-vs-postgresql-vs-mysql-choosing-the-right-database-4i14"&gt;SQLite vs PostgreSQL vs MySQL comparison&lt;/a&gt; covers when each one earns its place. For learning the AI workflow, SQLite wins on setup cost alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pointing Claude Code at a &lt;code&gt;.sqlite&lt;/code&gt; file
&lt;/h2&gt;

&lt;p&gt;Claude Code talks to databases through an MCP server — a small bridge process that exposes your database to the assistant as a set of tools. If the term is new, the &lt;a href="https://www.deployhq.com/blog/build-your-first-mcp-server-model-context-protocol-guide" rel="noopener noreferrer"&gt;complete guide to MCP servers&lt;/a&gt; explains what they are and why they matter. The one we use here is DBHub, and the full multi-engine setup lives in the &lt;a href="https://dev.to/deployhq/how-to-generate-sql-queries-with-ai-step-by-step-guide-using-claude-code-and-dbhub-422f"&gt;step-by-step guide to querying databases with Claude Code and DBHub&lt;/a&gt; — this post won't re-run that whole setup, just the SQLite-specific piece.&lt;/p&gt;

&lt;p&gt;DBHub supports SQLite directly, and the SQLite DSN is refreshingly short. Where Postgres needs a host, port, user, password, and database name, SQLite needs a path to a file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&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;mydb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sqlite&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;That is the entire connection string. The &lt;code&gt;sqlite://&lt;/code&gt; scheme followed by a path — three slashes for a relative path, four for an absolute one like &lt;code&gt;sqlite:////Users/you/data/mydb.sqlite&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In your Claude Code MCP configuration, DBHub takes that DSN as its &lt;code&gt;--dsn&lt;/code&gt; argument:&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;"mcpServers"&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;"dbhub"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"@bytebase/dbhub"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--transport"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--dsn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sqlite:///./mydb.sqlite"&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;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;Restart Claude Code, and it can now see your SQLite file's tables and columns. If you don't have a database to point at yet, DBHub also ships a demo mode that spins up an in-memory SQLite database, so you can kick the tires before pointing it at anything of your own. Keeping the exact DBHub flags handy is easier with the &lt;a href="https://www.deployhq.com/cheatsheets/claude-code" rel="noopener noreferrer"&gt;Claude Code cheatsheet&lt;/a&gt; open in another tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a file is the safest sandbox you can give an AI
&lt;/h2&gt;

&lt;p&gt;Here is the property that makes SQLite genuinely different for AI-assisted work, not just easier to set up: your entire database is one file, so a complete backup is one copy command.&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;cp &lt;/span&gt;mydb.sqlite mydb.backup.sqlite

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

&lt;/div&gt;



&lt;p&gt;That single line is your rollback plan. Before you let the assistant run anything destructive — an &lt;code&gt;ALTER TABLE&lt;/code&gt;, a bulk &lt;code&gt;UPDATE&lt;/code&gt;, a schema rewrite it suggested — you copy the file. If the change goes sideways, you restore 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="nb"&gt;cp &lt;/span&gt;mydb.backup.sqlite mydb.sqlite

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

&lt;/div&gt;



&lt;p&gt;You are back exactly where you started, byte for byte. There is no migration to reverse, no &lt;code&gt;pg_restore&lt;/code&gt;, no &lt;q&gt;hope the transaction rolled back cleanly.&lt;/q&gt; The safety net is the filesystem itself.&lt;/p&gt;

&lt;p&gt;This changes how you can work with an assistant. On a shared server you approach every write cautiously, because a mistake affects other people and is annoying to undo. Against a copied SQLite file, you can be reckless on purpose: let the assistant try an aggressive denormalization, ask it to rewrite five tables, tell it to backfill a column three different ways and compare. Every experiment starts from a fresh copy and ends with a &lt;code&gt;cp&lt;/code&gt; if it didn't pan out.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Prototype fearlessly, then promote deliberately.&lt;/strong&gt; The whole point of a disposable local database is that &lt;q&gt;undo&lt;/q&gt; is free — so use it to move fast locally, and save the careful, reviewed process for the moment your changes touch a real server. &lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Start free with DeployHQ&lt;/a&gt; when you're ready to put a proper deployment pipeline behind those changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A good habit is one backup per experiment: copy the file, name the copy after what you're about to try (&lt;code&gt;mydb.pre-index-test.sqlite&lt;/code&gt;), and keep the ones that produced results worth revisiting. Disk is cheap; a lost afternoon of exploration isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exploring schema and prototyping queries
&lt;/h2&gt;

&lt;p&gt;With the file connected and a backup in your pocket, the workflow becomes conversational. Instead of memorizing table names, you ask.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start by mapping the territory.&lt;/strong&gt; Ask the assistant to describe the schema — tables, columns, types, foreign keys. Because DBHub exposes the structure as tools, Claude Code reads it directly rather than guessing, so what it tells you reflects the actual file, not a hallucinated model of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then prototype against real shape.&lt;/strong&gt; Describe what you want in plain English — &lt;q&gt;show me the ten customers with the most orders in the last quarter&lt;/q&gt; — and let the assistant draft the SQL. Run it, read the result, and refine. Because you're working on a copy, you can run the query, realize the join is wrong, and iterate without any consequence beyond a few seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test schema changes the same way.&lt;/strong&gt; Ask for an index, a new column, or a normalized table split, apply it to your working copy, and check whether queries behave the way you expected. If the change made things worse, restore the backup and try a different shape. This tight loop — propose, apply, inspect, keep-or-revert — is the core value of a disposable database.&lt;/p&gt;

&lt;p&gt;A few practices keep the exploration honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the SQL before you run it.&lt;/strong&gt; The assistant is drafting, not deciding. Skim every statement, especially anything that writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up before writes, not before reads.&lt;/strong&gt; Selects can't hurt the file; &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, and &lt;code&gt;ALTER&lt;/code&gt; can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seed deterministically.&lt;/strong&gt; Keep a small script that rebuilds your test data so &lt;q&gt;start over&lt;/q&gt; is always available.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The moment you point an assistant at a networked, multi-user database instead of a local file, that &lt;code&gt;cp&lt;/code&gt; safety net disappears — which is exactly when you want to lock the connection down. Our guide to &lt;a href="https://dev.to/deployhq/how-to-let-ai-query-your-database-without-deleting-it-read-only-guardrails-for-claude-code-1jhi-temp-slug-5799689"&gt;read-only guardrails for AI database access&lt;/a&gt; covers the dedicated-role and MCP-flag recipe that keeps a shared database as safe as a throwaway file.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to graduate from SQLite
&lt;/h2&gt;

&lt;p&gt;SQLite is a superb sandbox and a perfectly good production database for plenty of workloads — but there is a point where you outgrow it. Move to PostgreSQL or MySQL when you need real concurrency (many clients writing at once), network access from multiple application servers, or engine-specific features SQLite doesn't offer. The comparison linked earlier lays out exactly where each of those lines sits.&lt;/p&gt;

&lt;p&gt;The reassuring part: the workflow you just learned transfers cleanly. Pointing Claude Code at Postgres or &lt;a href="https://www.deployhq.com/blog/connect-claude-code-to-mysql-auth-plugins-sockets-gotchas" rel="noopener noreferrer"&gt;MySQL through DBHub&lt;/a&gt; is the same pattern, only the DSN changes from a file path to a host, port, and credentials (and MySQL adds a few connection quirks of its own). What you can't carry over is the &lt;code&gt;cp&lt;/code&gt;-based safety net — which is precisely why doing your schema and query experimentation on SQLite first is worth it. You work out the shape where mistakes are free, and only take the validated result to an engine where they aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving validated queries into a real deployment
&lt;/h2&gt;

&lt;p&gt;Once a schema change or a set of queries has proven itself against your local file, it needs a disciplined path to production — because on a live database, the free-rollback luxury is gone. That is where the process flips from &lt;q&gt;experiment freely&lt;/q&gt; to &lt;q&gt;change carefully.&lt;/q&gt;&lt;/p&gt;

&lt;p&gt;The safe pattern is to capture the validated change as a versioned migration and apply it as part of your deployment, not by hand. Our guide on &lt;a href="https://www.deployhq.com/blog/how-do-you-handle-database-changes-during-a-deployment" rel="noopener noreferrer"&gt;how to handle database changes during a deployment&lt;/a&gt; covers version-controlling schema changes and running them automatically, and the deeper &lt;a href="https://dev.to/deployhq/database-migration-strategies-for-zero-downtime-deployments-a-step-by-step-guide-2kpj"&gt;database migration strategies for zero-downtime deployments&lt;/a&gt; walks through applying those changes without taking the application offline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; fits at exactly this handoff. A &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;build pipeline&lt;/a&gt; can run your migration step as part of every deploy, so the schema change you prototyped locally on SQLite ships the same way your code does — reviewed, versioned, and repeatable — instead of someone SSHing in to run SQL by hand. The local file gave you a place to be fearless; the pipeline gives you a place to be careful.&lt;/p&gt;

&lt;p&gt;That is the arc of AI-assisted database work done well: experiment where undo is free, promote where it isn't, and never confuse the two. SQLite is where you start precisely because it makes the first half of that sentence effortless.&lt;/p&gt;




&lt;p&gt;Ready to put a proper deployment pipeline behind your validated schema changes? Explore &lt;a href="https://www.deployhq.com/features" rel="noopener noreferrer"&gt;automated deployment for your stack&lt;/a&gt; and run your migrations automatically on every deploy.&lt;/p&gt;

&lt;p&gt;Questions or feedback? Reach us at &lt;a href="mailto:support@deployhq.com"&gt;support@deployhq.com&lt;/a&gt; or 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>tutorials</category>
    </item>
    <item>
      <title>Running AI Coding Agents in CI/CD: Claude Code, Codex, and Gemini CLI in Headless Mode</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Fri, 10 Jul 2026 14:47:36 +0000</pubDate>
      <link>https://dev.to/deployhq/running-ai-coding-agents-in-cicd-claude-code-codex-and-gemini-cli-in-headless-mode-558d</link>
      <guid>https://dev.to/deployhq/running-ai-coding-agents-in-cicd-claude-code-codex-and-gemini-cli-in-headless-mode-558d</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 10 of our series on AI coding assistants for developers. See also: &lt;a href="https://dev.to/deployhq/agentic-workflows-explained-how-ai-agents-are-changing-cicd-pipelines-nm0-temp-slug-2291085"&gt;Agentic Workflows Explained&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;GitHub's Copilot Coding Agent is convenient — assign an issue and it opens a PR. But it's a black box. You don't choose the model, you can't add custom tools, and you're locked into GitHub's pricing and rate limits.&lt;/p&gt;

&lt;p&gt;There's another way: run the same AI coding CLIs you use locally — &lt;a href="https://dev.to/deployhq/getting-started-with-claude-code-the-ai-coding-assistant-for-your-terminal-4cba"&gt;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;Codex CLI&lt;/a&gt;, and &lt;a href="https://dev.to/deployhq/getting-started-with-google-gemini-cli-open-source-ai-agent-for-your-terminal-25e1"&gt;Gemini CLI&lt;/a&gt; — as headless steps in your CI/CD pipeline. You control the prompt, the model, the tools, and the cost. The agent runs non-interactively, produces output, and the pipeline decides what to do with it.&lt;/p&gt;

&lt;p&gt;This is the &lt;q&gt;bring your own agent&lt;/q&gt; approach. It works on any CI platform that can run a shell command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Headless Mode for Each CLI
&lt;/h2&gt;

&lt;p&gt;Each tool has a way to run without interactive input — no terminal UI, no approval prompts, just input and output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claude Code
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Print mode — runs a single prompt, outputs result, exits&lt;/span&gt;
claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Analyze the git diff and generate a changelog entry"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; Edit,Write,Bash &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; json

&lt;span class="c"&gt;# With stdin piping&lt;/span&gt;
git diff HEAD~1 | claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Generate a changelog entry for these changes"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; text

&lt;span class="c"&gt;# With max tokens to control cost&lt;/span&gt;
claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Fix the failing test in tests/auth.test.ts"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; Edit,Read,Bash &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-turns&lt;/span&gt; 10

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

&lt;/div&gt;



&lt;p&gt;Key flags: &lt;code&gt;-p&lt;/code&gt; (print mode, non-interactive), &lt;code&gt;--allowedTools&lt;/code&gt; (restrict what the agent can do), &lt;code&gt;--output-format&lt;/code&gt; (json or text), &lt;code&gt;--max-turns&lt;/code&gt; (limit iterations).&lt;/p&gt;

&lt;h3&gt;
  
  
  Codex CLI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Full auto mode — no human approval needed&lt;/span&gt;
codex &lt;span class="nt"&gt;--approval-mode&lt;/span&gt; full-auto &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Update the README with the latest API changes"&lt;/span&gt;

&lt;span class="c"&gt;# With file restrictions&lt;/span&gt;
codex &lt;span class="nt"&gt;--approval-mode&lt;/span&gt; full-auto &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--full-auto-error-mode&lt;/span&gt; ask-user &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Add input validation to the user registration endpoint"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Key flags: &lt;code&gt;--approval-mode full-auto&lt;/code&gt; (no interactive prompts), &lt;code&gt;--quiet&lt;/code&gt; (minimal output).&lt;/p&gt;

&lt;h3&gt;
  
  
  Gemini CLI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Non-interactive with prompt&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Generate unit tests for src/utils/validator.ts"&lt;/span&gt; | gemini

&lt;span class="c"&gt;# With sandbox mode&lt;/span&gt;
gemini &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"Review the last commit for security issues"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sandbox&lt;/span&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Practical Use Cases with Full CI Config
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Auto-Generate Changelog from Git Diff
&lt;/h3&gt;

&lt;p&gt;The agent reads the diff since the last release tag and writes a human-readable changelog entry:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate Changelog&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v*'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;changelog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&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;Install Claude Code&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g @anthropic-ai/claude-code&lt;/span&gt;

      &lt;span class="pi"&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;Generate changelog entry&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;PREV_TAG=$(git describe --tags --abbrev=0 HEAD~1 2&amp;gt;/dev/null || echo "")&lt;/span&gt;
          &lt;span class="s"&gt;DIFF=$(git log ${PREV_TAG}..HEAD --oneline --no-merges)&lt;/span&gt;

          &lt;span class="s"&gt;echo "$DIFF" | claude -p "Generate a changelog entry in Keep a Changelog format. Group changes into Added, Changed, Fixed, Removed. Be concise — one line per change. Output only the changelog markdown, nothing else." \&lt;/span&gt;
            &lt;span class="s"&gt;--output-format text &amp;gt; CHANGELOG_ENTRY.md&lt;/span&gt;

      &lt;span class="pi"&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;Create release with changelog&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;gh release create ${{ github.ref_name }} \&lt;/span&gt;
            &lt;span class="s"&gt;--title "${{ github.ref_name }}" \&lt;/span&gt;
            &lt;span class="s"&gt;--notes-file CHANGELOG_ENTRY.md&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. AI-Powered Code Review on Changed Files
&lt;/h3&gt;

&lt;p&gt;Run the agent as a review step on every PR — it reads the diff and leaves comments:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AI Code Review&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ai-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&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;Install Claude Code&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g @anthropic-ai/claude-code&lt;/span&gt;

      &lt;span class="pi"&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;Run AI review&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;DIFF=$(gh pr diff ${{ github.event.pull_request.number }})&lt;/span&gt;

          &lt;span class="s"&gt;REVIEW=$(echo "$DIFF" | claude -p "Review this diff for:&lt;/span&gt;
          &lt;span class="s"&gt;1. Security issues (SQL injection, XSS, hardcoded secrets)&lt;/span&gt;
          &lt;span class="s"&gt;2. Missing error handling&lt;/span&gt;
          &lt;span class="s"&gt;3. Performance concerns (N+1 queries, unnecessary loops)&lt;/span&gt;
          &lt;span class="s"&gt;4. Missing input validation&lt;/span&gt;

          &lt;span class="s"&gt;Format as a markdown list. If no issues found, say 'No issues found.' Be specific — reference file names and line numbers." \&lt;/span&gt;
            &lt;span class="s"&gt;--output-format text \&lt;/span&gt;
            &lt;span class="s"&gt;--max-turns 3)&lt;/span&gt;

          &lt;span class="s"&gt;gh pr comment ${{ github.event.pull_request.number }} \&lt;/span&gt;
            &lt;span class="s"&gt;--body "## AI Code Review&lt;/span&gt;

          &lt;span class="s"&gt;$REVIEW&lt;/span&gt;

          &lt;span class="s"&gt;---&lt;/span&gt;
          &lt;span class="s"&gt;*Automated review by Claude Code. This is advisory — human review is still required.*"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Once an agent produces a change that passes review, the last mile is shipping it. Wire your CI into an &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;automated build pipeline&lt;/a&gt; and a green run deploys to your own server automatically — the agent writes, CI validates, and the release goes out with no manual step in between.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Auto-Fix CI Failures
&lt;/h3&gt;

&lt;p&gt;When tests fail, the agent reads the error output and proposes a fix:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Tests with Auto-Fix&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&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="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.head_ref }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&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;Run tests&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tests&lt;/span&gt;
        &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test 2&amp;gt;&amp;amp;1 | tee test-output.txt&lt;/span&gt;

      &lt;span class="pi"&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;AI fix attempt&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.tests.outcome == 'failure'&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npm install -g @anthropic-ai/claude-code&lt;/span&gt;

          &lt;span class="s"&gt;claude -p "The tests failed. Here's the output:&lt;/span&gt;

          &lt;span class="s"&gt;$(cat test-output.txt)&lt;/span&gt;

          &lt;span class="s"&gt;Read the failing test files and the source code they test.&lt;/span&gt;
          &lt;span class="s"&gt;Fix the source code to make the tests pass.&lt;/span&gt;
          &lt;span class="s"&gt;Do NOT modify the test files — fix the implementation." \&lt;/span&gt;
            &lt;span class="s"&gt;--allowedTools Read,Edit,Bash \&lt;/span&gt;
            &lt;span class="s"&gt;--max-turns 15&lt;/span&gt;

      &lt;span class="pi"&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;Verify fix&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.tests.outcome == 'failure'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;

      &lt;span class="pi"&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;Commit fix&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.tests.outcome == 'failure'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name "AI Agent"&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email "ai-agent@deployhq.com"&lt;/span&gt;
          &lt;span class="s"&gt;git add -A&lt;/span&gt;
          &lt;span class="s"&gt;git diff --staged --quiet || git commit -m "fix: auto-fix failing tests (AI-generated)"&lt;/span&gt;
          &lt;span class="s"&gt;git push&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Security Scanning with AI Triage
&lt;/h3&gt;

&lt;p&gt;Run a SAST tool, then have the AI prioritize and explain findings:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Security Scan + AI Triage&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&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;Run Semgrep&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;pip install semgrep&lt;/span&gt;
          &lt;span class="s"&gt;semgrep --config auto --json --output semgrep-results.json . || true&lt;/span&gt;

      &lt;span class="pi"&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;AI triage&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npm install -g @anthropic-ai/claude-code&lt;/span&gt;

          &lt;span class="s"&gt;TRIAGE=$(claude -p "Here are Semgrep security scan results:&lt;/span&gt;

          &lt;span class="s"&gt;$(cat semgrep-results.json)&lt;/span&gt;

          &lt;span class="s"&gt;Triage these findings:&lt;/span&gt;
          &lt;span class="s"&gt;1. Remove false positives (explain why)&lt;/span&gt;
          &lt;span class="s"&gt;2. Rank remaining issues by severity (Critical, High, Medium, Low)&lt;/span&gt;
          &lt;span class="s"&gt;3. For each real issue, explain the attack vector in one sentence&lt;/span&gt;
          &lt;span class="s"&gt;4. Suggest a fix for Critical and High issues&lt;/span&gt;

          &lt;span class="s"&gt;Format as a markdown table." \&lt;/span&gt;
            &lt;span class="s"&gt;--output-format text \&lt;/span&gt;
            &lt;span class="s"&gt;--max-turns 3)&lt;/span&gt;

          &lt;span class="s"&gt;echo "$TRIAGE" &amp;gt;&amp;gt; $GITHUB_STEP_SUMMARY&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Generate Tests for Modified Code
&lt;/h3&gt;

&lt;p&gt;When a PR changes source files, the agent generates missing tests:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Auto-Generate Tests&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;generate-tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.head_ref }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&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;Install Claude Code&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g @anthropic-ai/claude-code&lt;/span&gt;

      &lt;span class="pi"&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;Generate tests for changed files&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;CHANGED=$(gh pr diff ${{ github.event.pull_request.number }} --name-only | grep -E '\.(ts|js)$' | grep -v test | grep -v spec)&lt;/span&gt;

          &lt;span class="s"&gt;for FILE in $CHANGED; do&lt;/span&gt;
            &lt;span class="s"&gt;TEST_FILE=$(echo "$FILE" | sed 's/\.ts$/.test.ts/' | sed 's|src/|tests/unit/|')&lt;/span&gt;

            &lt;span class="s"&gt;if [! -f "$TEST_FILE"]; then&lt;/span&gt;
              &lt;span class="s"&gt;claude -p "Read $FILE and generate a comprehensive unit test file.&lt;/span&gt;
              &lt;span class="s"&gt;Use Vitest. Follow the existing test patterns in the project.&lt;/span&gt;
              &lt;span class="s"&gt;Save the test file to $TEST_FILE.&lt;/span&gt;
              &lt;span class="s"&gt;Test all exported functions including edge cases." \&lt;/span&gt;
                &lt;span class="s"&gt;--allowedTools Read,Write \&lt;/span&gt;
                &lt;span class="s"&gt;--max-turns 10&lt;/span&gt;
            &lt;span class="s"&gt;fi&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;

      &lt;span class="pi"&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;Verify tests pass&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;

      &lt;span class="pi"&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;Commit generated tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name "AI Agent"&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email "ai-agent@deployhq.com"&lt;/span&gt;
          &lt;span class="s"&gt;git add tests/&lt;/span&gt;
          &lt;span class="s"&gt;git diff --staged --quiet || git commit -m "test: add AI-generated tests for new code"&lt;/span&gt;
          &lt;span class="s"&gt;git push&lt;/span&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Other CI Platforms
&lt;/h2&gt;

&lt;p&gt;The same patterns work on any CI platform:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitLab CI:&lt;/strong&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;ai-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;review&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;node:20&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm install -g @anthropic-ai/claude-code&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git diff origin/main...HEAD | claude -p "Review this diff for bugs and security issues" --output-format text &amp;gt; review.md&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cat review.md&lt;/span&gt;
  &lt;span class="na"&gt;artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;reports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;dotenv&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;review.md&lt;/span&gt;
  &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$ANTHROPIC_API_KEY&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bitbucket Pipelines:&lt;/strong&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;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**'&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;step&lt;/span&gt;&lt;span class="pi"&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;AI Code Review&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;node:20&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm install -g @anthropic-ai/claude-code&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git diff origin/main...HEAD | claude -p "Review for security issues" --output-format text&lt;/span&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Connecting to Deployment
&lt;/h2&gt;

&lt;p&gt;After the agent's changes pass CI, you can trigger a deployment automatically using the &lt;a href="https://www.deployhq.com/support/cli" rel="noopener noreferrer"&gt;DeployHQ CLI&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;- name: Deploy to Staging
  &lt;span class="k"&gt;if&lt;/span&gt;: success&lt;span class="o"&gt;()&lt;/span&gt;
  run: |
    curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://www.deployhq.com/install-cli.sh | bash
    dhq auth login &lt;span class="nt"&gt;--token&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="p"&gt;{ secrets.DEPLOYHQ_API_KEY &lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;
    dhq deploy &lt;span class="nt"&gt;--server&lt;/span&gt; staging &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;This completes the loop: agent writes code → CI validates → agent fixes failures → the deployment pipeline ships it. The full pattern is covered in our companion post on &lt;a href="https://www.deployhq.com/blog/ai-agents-cicd-pipelines-deployhq-cli" rel="noopener noreferrer"&gt;connecting agentic CI/CD to DeployHQ&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Management
&lt;/h2&gt;

&lt;p&gt;Running AI agents in CI can get expensive fast. Here's how to keep costs under control:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;Savings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Limit turns&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--max-turns 10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prevents infinite loops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Restrict tools&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--allowedTools Read,Edit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fewer API calls per turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Filter triggers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only run on &lt;code&gt;src/&lt;/code&gt; changes&lt;/td&gt;
&lt;td&gt;Skip docs-only PRs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Use cheaper models&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Claude Haiku for simple tasks&lt;/td&gt;
&lt;td&gt;10x cheaper than Opus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cache results&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Skip review if diff unchanged&lt;/td&gt;
&lt;td&gt;Eliminates duplicate runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Set timeouts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;timeout-minutes: 10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hard stop on runaway agents&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A typical AI review step costs $0.02-0.10 per PR with Claude Sonnet. Auto-fix attempts cost more ($0.20-1.00) because the agent reads files and makes multiple edits. Budget accordingly — for most teams, this is cheaper than the developer time it saves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;Running an AI agent in CI means giving it access to your code and (potentially) your secrets. Lock it down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope secrets tightly.&lt;/strong&gt; The agent needs an API key for its model provider. It does NOT need your database credentials, deployment tokens, or SSH keys. Use GitHub's environment-level secrets to control access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrict file access.&lt;/strong&gt; Use &lt;code&gt;--allowedTools&lt;/code&gt; to prevent the agent from running arbitrary shell commands. &lt;code&gt;Read,Edit&lt;/code&gt; is sufficient for most review tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate output.&lt;/strong&gt; Don't blindly commit agent-generated changes. Run your full test suite after any agent modification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit agent commits.&lt;/strong&gt; Tag agent-generated commits with a machine author (&lt;code&gt;ai-agent@yourcompany.com&lt;/code&gt;) so they're easy to identify in git history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't run agents on forks.&lt;/strong&gt; Disable AI review steps on PRs from forked repositories — the fork author could craft a prompt injection in the diff.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to Use What
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Trade-offs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Platform agent&lt;/strong&gt; (Copilot)&lt;/td&gt;
&lt;td&gt;Issue-to-PR automation, managed infrastructure&lt;/td&gt;
&lt;td&gt;Less control, GitHub-only, opaque&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Headless CLI&lt;/strong&gt; (Claude/Codex/Gemini)&lt;/td&gt;
&lt;td&gt;Custom review, fix, and generation workflows&lt;/td&gt;
&lt;td&gt;More setup, you manage costs, any CI platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Dedicated review tool&lt;/strong&gt; (&lt;a href="https://dev.to/deployhq/ai-code-review-tools-compared-coderabbit-vs-github-copilot-code-review-vs-sourcery-vs-ellipsis-29og-temp-slug-397619"&gt;CodeRabbit&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;PR review specifically&lt;/td&gt;
&lt;td&gt;Best review UX, less flexible for other tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No agent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Simple projects, strict security requirements&lt;/td&gt;
&lt;td&gt;Maximum control, minimum risk&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most teams end up combining approaches: a dedicated review tool (CodeRabbit or Copilot) for every PR, plus headless CLI agents for specific workflows like changelog generation or test scaffolding.&lt;/p&gt;




&lt;p&gt;Running your own AI agent in CI gives you control that platform-managed agents can't match. You choose the model, the prompt, the tools, and the budget. The trade-off is setup and maintenance — but once the workflow YAML is written, it runs on every PR without intervention.&lt;/p&gt;

&lt;p&gt;Start with one workflow — changelog generation or AI code review — and expand from there. Keep costs visible, scope permissions tightly, and always run tests after agent modifications.&lt;/p&gt;

&lt;p&gt;Ready to connect your AI-powered CI pipeline to deployment? &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; deploys to any server you own with &lt;a href="https://www.deployhq.com/features/zero-downtime-deployments" rel="noopener noreferrer"&gt;zero-downtime deployments&lt;/a&gt;, &lt;a href="https://www.deployhq.com/features/one-click-rollback" rel="noopener noreferrer"&gt;one-click rollback&lt;/a&gt;, and a CLI built for CI/CD automation. &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>
    </item>
    <item>
      <title>Managing Secrets: .env Files, .env.example, and Config in DeployHQ</title>
      <dc:creator>DeployHQ</dc:creator>
      <pubDate>Wed, 08 Jul 2026 07:18:27 +0000</pubDate>
      <link>https://dev.to/deployhq/managing-secrets-env-files-envexample-and-config-in-deployhq-64i</link>
      <guid>https://dev.to/deployhq/managing-secrets-env-files-envexample-and-config-in-deployhq-64i</guid>
      <description>&lt;p&gt;Every application needs configuration that changes between environments — database URLs, API keys, feature flags. The &lt;code&gt;.env&lt;/code&gt; file is the standard way to manage these values locally without hardcoding them into your source code. This guide explains what &lt;code&gt;.env&lt;/code&gt; files are, how they work across languages, and how to handle them safely from development through &lt;a href="https://www.deployhq.com/features" rel="noopener noreferrer"&gt;deployment&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is a .env File?
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;.env&lt;/code&gt; file is a plain text file that sits in the root of your project and stores configuration as key-value pairs:&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_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;postgres://localhost:5432/myapp&lt;/span&gt;
&lt;span class="py"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sk-abc123def456&lt;/span&gt;
&lt;span class="py"&gt;DEBUG&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3000&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Your application reads this file at startup and loads the values into the process environment. This keeps sensitive credentials and environment-specific settings out of your codebase.&lt;/p&gt;

&lt;p&gt;The name &lt;code&gt;.env&lt;/code&gt; follows the Unix convention where dot-prefixed files are hidden by default — present but unobtrusive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The KEY=VALUE Format
&lt;/h2&gt;

&lt;p&gt;The syntax is simple, but there are rules worth knowing:&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="c"&gt;# This is a comment
&lt;/span&gt;&lt;span class="py"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;postgres://localhost:5432/myapp&lt;/span&gt;

&lt;span class="c"&gt;# Quoted values preserve whitespace
&lt;/span&gt;&lt;span class="py"&gt;APP_NAME&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"My Application"&lt;/span&gt;

&lt;span class="c"&gt;# Single quotes prevent variable expansion
&lt;/span&gt;&lt;span class="py"&gt;GREETING&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;'Hello $USER'&lt;/span&gt;

&lt;span class="c"&gt;# No spaces around the equals sign
&lt;/span&gt;&lt;span class="py"&gt;CORRECT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;
&lt;span class="c"&gt;# WRONG = value &amp;lt;-- this breaks most parsers
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No spaces around &lt;code&gt;=&lt;/code&gt;&lt;/strong&gt;. &lt;code&gt;KEY=value&lt;/code&gt; is correct; &lt;code&gt;KEY = value&lt;/code&gt; breaks most parsers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comments&lt;/strong&gt; start with &lt;code&gt;#&lt;/code&gt; at the beginning of a line&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty lines&lt;/strong&gt; are ignored&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quotes&lt;/strong&gt; are optional for simple values. Use double quotes for values with spaces, single quotes to prevent variable interpolation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;export&lt;/code&gt; keyword&lt;/strong&gt;. Unlike shell scripts, &lt;code&gt;.env&lt;/code&gt; files omit &lt;code&gt;export&lt;/code&gt;. Some parsers accept it, but it's not standard&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Different Frameworks Load .env Files
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; file doesn't load itself. Your language or framework needs a library to read it and inject values into the process environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node.js
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://github.com/motdotla/dotenv" rel="noopener noreferrer"&gt;dotenv&lt;/a&gt; package is the standard:&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;dotenv&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="s1"&gt;dotenv&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;config&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;dbUrl&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;DATABASE_URL&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;port&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;PORT&lt;/span&gt; &lt;span class="o"&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;p&gt;Node.js 20.6+ has a built-in &lt;code&gt;--env-file&lt;/code&gt; flag that eliminates the package entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--env-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env app.js

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Python
&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;pip&lt;/span&gt; &lt;span class="n"&gt;install&lt;/span&gt; &lt;span class="n"&gt;python&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;dotenv&lt;/span&gt;


&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;db_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DEBUG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&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;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Django projects often use &lt;a href="https://django-environ.readthedocs.io/" rel="noopener noreferrer"&gt;django-environ&lt;/a&gt; for typed access:&lt;br&gt;
&lt;/p&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;environ&lt;/span&gt;
&lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_env&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;DEBUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DEBUG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Ruby
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Gemfile&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s1"&gt;'dotenv'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;groups: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:development&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:test&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Load early in boot&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s1"&gt;'dotenv/load'&lt;/span&gt;

&lt;span class="n"&gt;db_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'DATABASE_URL'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Rails projects use &lt;code&gt;dotenv-rails&lt;/code&gt;, which loads automatically before the app initializes.&lt;/p&gt;

&lt;h3&gt;
  
  
  PHP
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="n"&gt;composer&lt;/span&gt; &lt;span class="k"&gt;require&lt;/span&gt; &lt;span class="n"&gt;vlucas&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;phpdotenv&lt;/span&gt;


&lt;span class="nv"&gt;$dotenv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Dotenv\Dotenv&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;createImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$dotenv&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$dbUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'DATABASE_URL'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// Require specific variables (throws exception if missing)&lt;/span&gt;
&lt;span class="nv"&gt;$dotenv&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'DATABASE_URL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'API_KEY'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Laravel loads &lt;code&gt;.env&lt;/code&gt; automatically. Access values with the &lt;code&gt;env()&lt;/code&gt; helper:&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="nv"&gt;$debug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'APP_DEBUG'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&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're shipping a Laravel app to production, our &lt;a href="https://dev.to/theqadiariesforyou/how-to-deploy-laravel-zero-downtime-build-pipelines-and-best-practices-1jcj-temp-slug-5099175"&gt;zero-downtime Laravel deployment guide&lt;/a&gt; walks through build pipelines and environment configuration end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Best Practices
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; file often contains the most sensitive data in your project. Mishandling it is one of the most common security mistakes in web development.&lt;/p&gt;

&lt;h3&gt;
  
  
  Never Commit .env to Version Control
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;.env&lt;/code&gt; to your &lt;a href="https://www.deployhq.com/blog/understanding-keep-and-gitkeep-files-a-guide" rel="noopener noreferrer"&gt;.gitignore&lt;/a&gt; immediately:&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;span class="err"&gt;.env.local&lt;/span&gt;
&lt;span class="err"&gt;.env.*.local&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;If you've already committed a &lt;code&gt;.env&lt;/code&gt; file, removing it from &lt;code&gt;.gitignore&lt;/code&gt; isn't enough — the values are still in your Git history. Rotate every exposed secret, then use &lt;a href="https://rtyley.github.io/bfg-repo-cleaner/" rel="noopener noreferrer"&gt;BFG Repo-Cleaner&lt;/a&gt; to scrub the history. Our guide to &lt;a href="https://www.deployhq.com/blog/protecting-your-api-keys-a-quick-guide" rel="noopener noreferrer"&gt;protecting your API keys&lt;/a&gt; covers rotation and leak response in more depth.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use .env.example as a Template
&lt;/h3&gt;

&lt;p&gt;Create a &lt;code&gt;.env.example&lt;/code&gt; that documents every required variable with placeholder values:&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="c"&gt;# .env.example - Copy to .env and fill in real values
&lt;/span&gt;&lt;span class="py"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;postgres://user:password@localhost:5432/dbname&lt;/span&gt;
&lt;span class="py"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your-api-key-here&lt;/span&gt;
&lt;span class="py"&gt;DEBUG&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3000&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Commit this file. New developers copy it, fill in their values, and they're ready to go.&lt;/p&gt;

&lt;h3&gt;
  
  
  Restrict File Permissions
&lt;/h3&gt;

&lt;p&gt;On Unix systems:&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;chmod &lt;/span&gt;600 .env

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

&lt;/div&gt;



&lt;p&gt;This ensures only the file owner can read or write it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment-Specific Files
&lt;/h2&gt;

&lt;p&gt;Most frameworks support a hierarchy of &lt;code&gt;.env&lt;/code&gt; files that override each other:&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;span class="c"&gt;# Shared defaults (committed)
&lt;/span&gt;&lt;span class="err"&gt;.env.local&lt;/span&gt; &lt;span class="c"&gt;# Local overrides (not committed)
&lt;/span&gt;&lt;span class="err"&gt;.env.development&lt;/span&gt; &lt;span class="c"&gt;# Development-specific
&lt;/span&gt;&lt;span class="err"&gt;.env.test&lt;/span&gt; &lt;span class="c"&gt;# Test-specific
&lt;/span&gt;&lt;span class="err"&gt;.env.production&lt;/span&gt; &lt;span class="c"&gt;# Production-specific
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The typical loading order (varies by framework):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;.env&lt;/code&gt; — base defaults&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env.local&lt;/code&gt; — local overrides (skipped in test)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env.{environment}&lt;/code&gt; — environment-specific values&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env.{environment}.local&lt;/code&gt; — local environment overrides&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Vite&lt;/strong&gt; only exposes variables prefixed with &lt;code&gt;VITE_&lt;/code&gt; to client-side code. &lt;strong&gt;Next.js&lt;/strong&gt; uses &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;. &lt;strong&gt;Create React App&lt;/strong&gt; uses &lt;code&gt;REACT_APP_&lt;/code&gt;. These prefixes prevent accidental exposure of server-side secrets to the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Patterns and Gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Variable Expansion
&lt;/h3&gt;

&lt;p&gt;Some parsers support referencing other variables:&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;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com&lt;/span&gt;
&lt;span class="py"&gt;USERS_API&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${BASE_URL}/users&lt;/span&gt;
&lt;span class="py"&gt;POSTS_API&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${BASE_URL}/posts&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dotenv-expand&lt;/code&gt; package enables this in Node.js. Python's &lt;code&gt;python-dotenv&lt;/code&gt; supports it natively. Not all parsers handle expansion, so test before relying on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multiline Values
&lt;/h3&gt;

&lt;p&gt;Multiline values require double quotes:&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;PRIVATE_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF...
-----END RSA PRIVATE KEY-----"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;If your parser struggles with this, base64-encode the value instead:&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;PRIVATE_KEY_BASE64&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;TUlJRXBBSUJBQUtDQVFF&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&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;PRIVATE_KEY_BASE64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Boolean Values
&lt;/h3&gt;

&lt;p&gt;There's no standard for booleans. These are all common:&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;DEBUG&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;DEBUG&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;DEBUG&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Pick one convention and be consistent. Always parse explicitly:&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;debug&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;DEBUG&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configuring Secrets in DeployHQ: UI, CLI, and API
&lt;/h2&gt;

&lt;p&gt;During local development, &lt;code&gt;.env&lt;/code&gt; files are convenient. In production, you should not deploy &lt;code&gt;.env&lt;/code&gt; files to your servers. Once your secrets are out of your repository, you still need a way to store them and inject them at deploy time. &lt;a href="https://www.deployhq.com/features/build-pipelines" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; gives you three ways to do that — the web UI, the &lt;code&gt;dhq&lt;/code&gt; command-line tool, and the API — so you can pick whichever fits a one-off change or an automated workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Config Files vs Environment Variables
&lt;/h3&gt;

&lt;p&gt;Before configuring anything, it helps to know which of the two mechanisms you actually need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Config files&lt;/strong&gt; are whole files uploaded at deploy time and &lt;em&gt;not&lt;/em&gt; stored in your repository — think a production &lt;code&gt;.env&lt;/code&gt;, a &lt;code&gt;credentials.json&lt;/code&gt;, or a &lt;code&gt;settings.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment variables&lt;/strong&gt; are individual values injected into the build and referenced from config files or SSH commands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule of thumb is simple: if you're managing a single value, use an environment variable; if you're managing an entire file, use a config file.&lt;/p&gt;

&lt;h3&gt;
  
  
  The UI Method
&lt;/h3&gt;

&lt;p&gt;For a one-off change, the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; web UI is the fastest path.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com/support/config-files" rel="noopener noreferrer"&gt;&lt;strong&gt;Config files&lt;/strong&gt;&lt;/a&gt; live under &lt;strong&gt;Settings → Config Files&lt;/strong&gt;. They're uploaded during deployment rather than stored in your repository, which makes them ideal for sensitive data like database configuration. When you create one, you specify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The full path as the filename (this is where the file lands on the server)&lt;/li&gt;
&lt;li&gt;The file content&lt;/li&gt;
&lt;li&gt;An optional description&lt;/li&gt;
&lt;li&gt;A language for syntax highlighting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two toggles shape how the file is used. &lt;strong&gt;Build Pipeline Integration&lt;/strong&gt; makes the config file available during your build commands. &lt;strong&gt;Server Distribution&lt;/strong&gt; lets you upload it to all current and future servers and server groups, or target specific ones. You can also set up multiple config files per environment type — development, staging, and production — and only deploy them where they belong. Config files can go out with a standard deployment, or via a dedicated &lt;strong&gt;Config file deployment&lt;/strong&gt; that ships only config files with no other changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.deployhq.com/support/projects/environment-variables" rel="noopener noreferrer"&gt;&lt;strong&gt;Environment variables&lt;/strong&gt;&lt;/a&gt; live in the project sidebar under &lt;strong&gt;Environment Variables&lt;/strong&gt;. These are dynamic values usable in config files, SSH commands, and notifications. To create one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The name must start with a letter and contain only uppercase letters, numbers, and underscores&lt;/li&gt;
&lt;li&gt;Enter a value&lt;/li&gt;
&lt;li&gt;Toggle &lt;strong&gt;Use this variable with the Build Pipeline&lt;/strong&gt; if the build needs it&lt;/li&gt;
&lt;li&gt;Choose which server or server group can access it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the build pipeline, these are exported as real environment variables, so tools like Vite, Webpack, Next.js, and Create React App read them via &lt;code&gt;process.env.VARIABLE_NAME&lt;/code&gt; with no &lt;code&gt;.env&lt;/code&gt; file setup at all. In deployments, config files, and SSH commands, you reference them with the &lt;code&gt;%VARIABLE_NAME%&lt;/code&gt; syntax.&lt;/p&gt;

&lt;p&gt;On security: all variable values are &lt;strong&gt;encrypted at rest&lt;/strong&gt; and never visible in logs or the UI. Values can also be &lt;strong&gt;locked&lt;/strong&gt; , which prevents viewing or changing them while they keep working in deployments. When the same variable is defined at multiple levels, targeting precedence runs highest to lowest: server-specific overrides a server group, which overrides all-servers. &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; also ships roughly 20 built-in variables — such as &lt;code&gt;%environment%&lt;/code&gt;, &lt;code&gt;%branch%&lt;/code&gt;, &lt;code&gt;%deployer%&lt;/code&gt;, and &lt;code&gt;%status%&lt;/code&gt; — that you can reference without defining anything.&lt;/p&gt;

&lt;p&gt;If you're new to how the build stage fits into a deployment, &lt;a href="https://www.deployhq.com/blog/the-deployhq-build-pipeline" rel="noopener noreferrer"&gt;the&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; build pipeline explains where these variables and files are injected.&lt;/p&gt;

&lt;h3&gt;
  
  
  The CLI Method
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://www.deployhq.com/support/cli" rel="noopener noreferrer"&gt;&lt;code&gt;dhq&lt;/code&gt; command-line tool&lt;/a&gt; is a single binary that brings the &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; workflow to your terminal — deploy, monitor, manage servers, and automate, all without leaving the shell.&lt;/p&gt;

&lt;p&gt;Install it with Homebrew, the install script, or a prebuilt binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;deployhq/tap/dhq
&lt;span class="c"&gt;# or&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/deployhq/deployhq-cli/main/install.sh | sh

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

&lt;/div&gt;



&lt;p&gt;You can also grab a binary directly from GitHub Releases.&lt;/p&gt;

&lt;p&gt;Authenticate once with &lt;code&gt;dhq auth login&lt;/code&gt;, which stores your credentials in the OS keyring. &lt;code&gt;dhq hello&lt;/code&gt; gives you a guided login/signup flow plus project selection, and for CI/CD you authenticate via environment variables instead of the interactive login.&lt;/p&gt;

&lt;p&gt;The commands relevant to secrets are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dhq env-vars&lt;/code&gt; — manage a project's environment variables&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dhq global-env-vars&lt;/code&gt; — manage account-wide environment variables&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dhq config-files&lt;/code&gt; — manage configuration files&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dhq api&lt;/code&gt; — reach the full set of 144+ API endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CLI also offers JSON output for scripting and shell completions for faster typing.&lt;/p&gt;

&lt;p&gt;The exact create, set, and update sub-command syntax isn't fixed here — run &lt;code&gt;dhq &amp;lt;command&amp;gt; --help&lt;/code&gt; (for example, &lt;code&gt;dhq env-vars --help&lt;/code&gt;) to see the current usage for your version rather than guessing at flags.&lt;/p&gt;

&lt;h3&gt;
  
  
  The API Method
&lt;/h3&gt;

&lt;p&gt;For fully programmatic management, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; exposes &lt;strong&gt;144+ API endpoints&lt;/strong&gt; , reachable either through &lt;code&gt;dhq api&lt;/code&gt; or directly. This is the right layer when you want to script secret provisioning — for instance, auto-creating a standard set of environment variables and config files every time you onboard a new project or client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which One Should You Use?
&lt;/h3&gt;

&lt;p&gt;The CLI and API make your secrets reproducible and automatable, which is exactly what you want when they need to live in CI or in an onboarding script that spins up a new project the same way every time. The UI, by contrast, is the quickest route for a one-off change. Most teams end up using all three: the UI to eyeball and tweak, the CLI and API to codify.&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[Code in Git] --&amp;gt; B[DeployHQ]
  B --&amp;gt; C[Build with env vars]
  C --&amp;gt; D[Deploy to server]
  E[Secure variable store] --&amp;gt; B

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

&lt;/div&gt;



&lt;p&gt;The end-to-end workflow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Development&lt;/strong&gt; : Use &lt;code&gt;.env&lt;/code&gt; files locally&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/Build&lt;/strong&gt; : Environment variables and config files set in DeployHQ's project configuration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment&lt;/strong&gt; : Variables available during build commands and injected into config files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime&lt;/strong&gt; : Application reads from the server's environment&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you deploy from &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;, &lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; connects to your repository and handles the rest. For &lt;a href="https://www.deployhq.com/for-agencies" rel="noopener noreferrer"&gt;agencies&lt;/a&gt; managing multiple client projects, each project gets an isolated variable store so credentials never leak between clients. This same config-in-the-environment discipline is the backbone of &lt;a href="https://dev.to/deployhq/implementing-12-factor-app-methodology-with-deployhq-38lh"&gt;the 12-Factor App methodology&lt;/a&gt;, and it applies just as much to a &lt;a href="https://dev.to/deployhq/deployhq-static-hosting-vs-cloudflare-pages-same-edge-different-workflows-47jg-temp-slug-7657484"&gt;static site on Cloudflare's edge&lt;/a&gt; as it does to a traditional server.&lt;/p&gt;

&lt;p&gt;Ready to get your secrets out of Git for good? &lt;strong&gt;&lt;a href="https://www.deployhq.com/signup" rel="noopener noreferrer"&gt;Start a&lt;/a&gt;&lt;a href="https://www.deployhq.com" rel="noopener noreferrer"&gt;DeployHQ&lt;/a&gt; project&lt;/strong&gt; and add your first encrypted environment variable in a couple of minutes.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Should I commit .env.example to Git?&lt;/strong&gt; Yes. It documents which variables your app requires with placeholder values. Never put real credentials in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if a variable is defined in both .env and the system environment?&lt;/strong&gt; In most libraries, system environment variables take precedence. This lets your deployment platform override defaults without modifying files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use .env files in Docker containers?&lt;/strong&gt; Yes. Docker supports &lt;code&gt;--env-file&lt;/code&gt; natively, and Docker Compose reads &lt;code&gt;.env&lt;/code&gt; in the project root automatically for variable substitution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle .env files across a team?&lt;/strong&gt; Use &lt;code&gt;.env.example&lt;/code&gt; as the shared template. Each developer maintains their own &lt;code&gt;.env&lt;/code&gt; with local values. For shared secrets, use a password vault rather than distributing files via chat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are .env files secure enough for production?&lt;/strong&gt; For local development, yes. For production, use your platform's built-in secrets management — like DeployHQ's secure environment variables, which are encrypted at rest and injected at deploy time.&lt;/p&gt;




&lt;p&gt;Ready to stop worrying about environment configuration? &lt;strong&gt;&lt;a href="https://www.deployhq.com/features/automatic-deployments" rel="noopener noreferrer"&gt;Automate deployments with DeployHQ's build pipeline&lt;/a&gt;&lt;/strong&gt; and manage your environment variables securely from day one. See &lt;a href="https://www.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>tipstricks</category>
    </item>
  </channel>
</rss>
