<?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: Pratham Chauhan</title>
    <description>The latest articles on DEV Community by Pratham Chauhan (@prathamchauhan).</description>
    <link>https://dev.to/prathamchauhan</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%2F4009350%2F278fe6e2-9e2a-4090-b517-ec6f4df96570.png</url>
      <title>DEV Community: Pratham Chauhan</title>
      <link>https://dev.to/prathamchauhan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/prathamchauhan"/>
    <language>en</language>
    <item>
      <title>Self Hosting ClickHouse on a Shared EC2 Box: Private Access via Cloudflare Tunnel + Automated S3 Backups</title>
      <dc:creator>Pratham Chauhan</dc:creator>
      <pubDate>Fri, 17 Jul 2026 15:12:34 +0000</pubDate>
      <link>https://dev.to/prathamchauhan/self-hosting-clickhouse-on-a-shared-ec2-box-private-access-via-cloudflare-tunnel-automated-s3-336e</link>
      <guid>https://dev.to/prathamchauhan/self-hosting-clickhouse-on-a-shared-ec2-box-private-access-via-cloudflare-tunnel-automated-s3-336e</guid>
      <description>&lt;p&gt;I recently needed an analytics database for a side project. ClickHouse was the obvious pick, it’s stupidly fast for the append heavy, aggregate later workload I had. What I &lt;em&gt;didn’t&lt;/em&gt; want was a managed cloud bill, and I already had an EC2 instance running a few other services. So the plan became: run ClickHouse on that same box, keep it completely off the public internet, let a separate web app read from it, and never lose the data.&lt;/p&gt;

&lt;p&gt;This is the full walkthrough install, tuning it to co exist with other services on a small box, locking down users, exposing it to a remote app through a Cloudflare Tunnel (zero open ports), and a nightly S3 backup. I’ve folded in every wall I hit along the way as callouts, because those were the parts that actually cost me time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The shape of it.&lt;/strong&gt; ClickHouse listens on &lt;code&gt;localhost&lt;/code&gt; only. A &lt;code&gt;cloudflared&lt;/code&gt; daemon on the same box makes an &lt;em&gt;outbound&lt;/em&gt; connection to Cloudflare; a remote app reaches the DB through that tunnel, gated by a Cloudflare Access service token. Backups go to S3 via an EC2 IAM role. No inbound ports for the database, ever.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My box for reference: &lt;strong&gt;2 vCPU / 8 GB RAM, Ubuntu&lt;/strong&gt;, shared with a few other services.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Install ClickHouse
&lt;/h2&gt;

&lt;p&gt;Straight from the official deb repo:&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-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; apt-transport-https ca-certificates curl gnupg
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="s1"&gt;'https://packages.clickhouse.com/rpm/lts/repodata/repomd.xml.key'&lt;/span&gt; | &lt;span class="nb"&gt;sudo &lt;/span&gt;gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/share/keyrings/clickhouse-keyring.gpg
&lt;span class="nv"&gt;ARCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [signed-by=/usr/share/keyrings/clickhouse-keyring.gpg arch=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARCH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] https://packages.clickhouse.com/deb stable main"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/clickhouse.list
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; clickhouse-server clickhouse-client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installer prompts you to set a password for the &lt;code&gt;default&lt;/code&gt; (admin) user. Set one and save it — it lands in &lt;code&gt;/etc/clickhouse-server/users.d/default-password.xml&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Gotcha  one command per line.&lt;/strong&gt; Don’t paste that GPG line and the &lt;code&gt;ARCH=&lt;/code&gt; line together as one line. If they get concatenated, &lt;code&gt;gpg&lt;/code&gt; tries to open &lt;code&gt;ARCH=amd64&lt;/code&gt; as a file and fails with a cryptic “No such file or directory”. Same with the &lt;code&gt;curl ... | sudo&lt;/code&gt; pipe if a paste wraps it across lines, &lt;code&gt;sudo&lt;/code&gt; runs with no command. Run each as its own line.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;By default ClickHouse binds only to &lt;code&gt;127.0.0.1&lt;/code&gt;/&lt;code&gt;::1&lt;/code&gt;  leave it that way. Everything else in this guide relies on it being loopback only.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Add swap (cheap OOM insurance)
&lt;/h2&gt;

&lt;p&gt;Small instances usually have no swap, and ClickHouse merges can spike memory. A swapfile keeps the OOM killer from murdering the process (or worse, a neighbour 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;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 4G /swapfile &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkswap /swapfile &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/swapfile none swap sw 0 0'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  3. Tune memory for a shared box
&lt;/h2&gt;

&lt;p&gt;ClickHouse’s defaults assume it owns a big server: a 5 GiB mark cache, generous background pools. On a shared 8 GB box that will starve everything else. I capped it with an &lt;strong&gt;absolute&lt;/strong&gt; memory limit (not a RAM ratio, a ratio would grab 80% of total RAM and choke my other services), plus trimmed caches and noisy system logs.&lt;/p&gt;

&lt;p&gt;Best practice is to leave &lt;code&gt;config.xml&lt;/code&gt; untouched and drop overrides into &lt;code&gt;config.d/&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="nb"&gt;sudo tee&lt;/span&gt; /etc/clickhouse-server/config.d/z-low-memory.xml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;XML&lt;/span&gt;&lt;span class="sh"&gt;'
&amp;lt;clickhouse&amp;gt;
    &amp;lt;max_server_memory_usage&amp;gt;2684354560&amp;lt;/max_server_memory_usage&amp;gt;   &amp;lt;!-- 2.5 GiB hard cap --&amp;gt;
    &amp;lt;mark_cache_size&amp;gt;268435456&amp;lt;/mark_cache_size&amp;gt;                    &amp;lt;!-- 256 MiB, was 5 GiB --&amp;gt;
    &amp;lt;max_concurrent_queries&amp;gt;10&amp;lt;/max_concurrent_queries&amp;gt;
    &amp;lt;mlock_executable&amp;gt;false&amp;lt;/mlock_executable&amp;gt;
    &amp;lt;metric_log remove="1"/&amp;gt;
    &amp;lt;asynchronous_metric_log remove="1"/&amp;gt;
    &amp;lt;trace_log remove="1"/&amp;gt;
    &amp;lt;text_log remove="1"/&amp;gt;
&amp;lt;/clickhouse&amp;gt;
&lt;/span&gt;&lt;span class="no"&gt;XML
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart clickhouse-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the hard cap, ClickHouse throws a clean “memory limit exceeded” at 2.5 GiB instead of getting OOM killed and taking neighbours down with it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Gotcha  don’t lower &lt;code&gt;background_pool_size&lt;/code&gt;.&lt;/strong&gt; My first instinct was to also set &lt;code&gt;&amp;lt;background_pool_size&amp;gt;2&amp;lt;/background_pool_size&amp;gt;&lt;/code&gt; to save resources. The server then refused to boot:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB::Exception: The value of 'number_of_free_entries_in_pool_to_execute_mutation' setting (20)
is greater than 'background_pool_size' * 'background_merges_mutations_concurrency_ratio' (4).
... mutations cannot work with these settings. (BAD_ARGUMENTS)
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;ClickHouse has a startup sanity check: &lt;code&gt;background_pool_size × concurrency_ratio (2)&lt;/code&gt; must exceed the mutation free entry threshold (default 20). With a pool of 2, that’s &lt;code&gt;2 × 2 = 4 &amp;lt; 20&lt;/code&gt;, so it hard fails. Those pool threads are mostly idle anyway and cost almost no memory  the real lever is &lt;code&gt;max_server_memory_usage&lt;/code&gt;. I just removed the override and let it default. If you genuinely must shrink it, you also have to lower three &lt;code&gt;&amp;lt;merge_tree&amp;gt;&lt;/code&gt; free entry settings, which isn’t worth it. &lt;strong&gt;Whenever a config change won’t boot, check &lt;code&gt;/var/log/clickhouse-server/clickhouse-server.err.log&lt;/code&gt; the exception names the exact setting.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Verify the cap is live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;clickhouse-client &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"SELECT value FROM system.server_settings WHERE name='max_server_memory_usage'"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; 2684354560&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  4. Create users the file based way (one read write, one read only)
&lt;/h2&gt;

&lt;p&gt;I wanted two dedicated users instead of throwing &lt;code&gt;default&lt;/code&gt; around: &lt;code&gt;app_rw&lt;/code&gt; for the service that writes, and &lt;code&gt;app_ro&lt;/code&gt; for the web app that only reads. ClickHouse supports declarative XML users in &lt;code&gt;users.d/&lt;/code&gt;  passwords as SHA 256 hashes, per user settings profiles, quotas, and scoped grants.&lt;/p&gt;

&lt;p&gt;First, hash each password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'your-write-password'&lt;/span&gt;    | &lt;span class="nb"&gt;sha256sum&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;   &lt;span class="c"&gt;# -&amp;gt; WRITE_HASH&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'your-readonly-password'&lt;/span&gt; | &lt;span class="nb"&gt;sha256sum&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;   &lt;span class="c"&gt;# -&amp;gt; RO_HASH&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then define both users. &lt;code&gt;readonly=1&lt;/code&gt; blocks all writes/DDL at the query level; the &lt;code&gt;&amp;lt;grants&amp;gt;&lt;/code&gt; block scopes each user to just the &lt;code&gt;metrics&lt;/code&gt; 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 tee&lt;/span&gt; /etc/clickhouse-server/users.d/z-app-users.xml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;XML&lt;/span&gt;&lt;span class="sh"&gt;'
&amp;lt;clickhouse&amp;gt;
    &amp;lt;profiles&amp;gt;
        &amp;lt;rw_profile&amp;gt;
            &amp;lt;max_memory_usage&amp;gt;1500000000&amp;lt;/max_memory_usage&amp;gt;
            &amp;lt;max_threads&amp;gt;2&amp;lt;/max_threads&amp;gt;
            &amp;lt;max_bytes_before_external_group_by&amp;gt;1000000000&amp;lt;/max_bytes_before_external_group_by&amp;gt;
            &amp;lt;max_bytes_before_external_sort&amp;gt;1000000000&amp;lt;/max_bytes_before_external_sort&amp;gt;
        &amp;lt;/rw_profile&amp;gt;
        &amp;lt;ro_profile&amp;gt;
            &amp;lt;readonly&amp;gt;1&amp;lt;/readonly&amp;gt;
            &amp;lt;max_memory_usage&amp;gt;1500000000&amp;lt;/max_memory_usage&amp;gt;
            &amp;lt;max_threads&amp;gt;2&amp;lt;/max_threads&amp;gt;
        &amp;lt;/ro_profile&amp;gt;
    &amp;lt;/profiles&amp;gt;

    &amp;lt;users&amp;gt;
        &amp;lt;!-- writer: the service running on this same box --&amp;gt;
        &amp;lt;app_rw&amp;gt;
            &amp;lt;password_sha256_hex&amp;gt;WRITE_HASH&amp;lt;/password_sha256_hex&amp;gt;
            &amp;lt;networks&amp;gt;&amp;lt;ip&amp;gt;::1&amp;lt;/ip&amp;gt;&amp;lt;ip&amp;gt;127.0.0.1&amp;lt;/ip&amp;gt;&amp;lt;/networks&amp;gt;
            &amp;lt;profile&amp;gt;rw_profile&amp;lt;/profile&amp;gt;
            &amp;lt;quota&amp;gt;default&amp;lt;/quota&amp;gt;
            &amp;lt;grants&amp;gt;
                &amp;lt;query&amp;gt;GRANT CREATE DATABASE ON metrics.*&amp;lt;/query&amp;gt;
                &amp;lt;query&amp;gt;GRANT ALL ON metrics.*&amp;lt;/query&amp;gt;
            &amp;lt;/grants&amp;gt;
        &amp;lt;/app_rw&amp;gt;

        &amp;lt;!-- reader: the remote web app, via the tunnel --&amp;gt;
        &amp;lt;app_ro&amp;gt;
            &amp;lt;password_sha256_hex&amp;gt;RO_HASH&amp;lt;/password_sha256_hex&amp;gt;
            &amp;lt;networks&amp;gt;&amp;lt;ip&amp;gt;::1&amp;lt;/ip&amp;gt;&amp;lt;ip&amp;gt;127.0.0.1&amp;lt;/ip&amp;gt;&amp;lt;/networks&amp;gt;
            &amp;lt;profile&amp;gt;ro_profile&amp;lt;/profile&amp;gt;
            &amp;lt;quota&amp;gt;ro_quota&amp;lt;/quota&amp;gt;
            &amp;lt;grants&amp;gt;
                &amp;lt;query&amp;gt;GRANT SELECT ON metrics.*&amp;lt;/query&amp;gt;
            &amp;lt;/grants&amp;gt;
        &amp;lt;/app_ro&amp;gt;
    &amp;lt;/users&amp;gt;

    &amp;lt;quotas&amp;gt;
        &amp;lt;ro_quota&amp;gt;
            &amp;lt;interval&amp;gt;
                &amp;lt;duration&amp;gt;3600&amp;lt;/duration&amp;gt;
                &amp;lt;queries&amp;gt;2000&amp;lt;/queries&amp;gt;
                &amp;lt;result_rows&amp;gt;100000000&amp;lt;/result_rows&amp;gt;
            &amp;lt;/interval&amp;gt;
        &amp;lt;/ro_quota&amp;gt;
    &amp;lt;/quotas&amp;gt;
&amp;lt;/clickhouse&amp;gt;
&lt;/span&gt;&lt;span class="no"&gt;XML
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart clickhouse-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Why &lt;code&gt;networks&lt;/code&gt; is localhost for both users&lt;/strong&gt; even the remote reader. The tunnel daemon (next section) opens a fresh connection to &lt;code&gt;localhost:8123&lt;/code&gt;, so ClickHouse sees &lt;em&gt;every&lt;/em&gt; request as coming from &lt;code&gt;127.0.0.1&lt;/code&gt;, remote or not. Locking the users to loopback is both correct and the tightest setting.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;Gotcha &lt;code&gt;GRANT ALL ON db.*&lt;/code&gt; does not include &lt;code&gt;CREATE DATABASE&lt;/code&gt;.&lt;/strong&gt; When my writer first tried to create the database, it got:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app_rw: Not enough privileges. To execute this query, it's necessary to have the grant CREATE DATABASE ON metrics.*
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Database level &lt;code&gt;ALL&lt;/code&gt; covers everything &lt;em&gt;inside&lt;/em&gt; the database (tables, inserts, selects, alters) but not the privilege to create the database itself that’s a separate grant. Hence the explicit &lt;code&gt;GRANT CREATE DATABASE ON metrics.*&lt;/code&gt; line above. It’s scoped to the name &lt;code&gt;metrics&lt;/code&gt;, so the user can only ever create that one database.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sanity check the read only user really is read only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;clickhouse-client &lt;span class="nt"&gt;-u&lt;/span&gt; app_ro &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; metrics &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"CREATE TABLE t (x Int8) ENGINE=Memory"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; "Cannot execute query in readonly mode"  ✅&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Create the database and tables
&lt;/h2&gt;

&lt;p&gt;Generic schema  an &lt;code&gt;events&lt;/code&gt; table with a monthly partition and a 12 month TTL, which is a sensible default for time series analytics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;clickhouse-client &lt;span class="nt"&gt;-u&lt;/span&gt; app_rw &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"CREATE DATABASE IF NOT EXISTS metrics"&lt;/span&gt;

clickhouse-client &lt;span class="nt"&gt;-u&lt;/span&gt; app_rw &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; metrics &lt;span class="nt"&gt;--multiquery&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;SQL&lt;/span&gt;&lt;span class="sh"&gt;'
CREATE TABLE IF NOT EXISTS events
(
    event_time  DateTime64(3, 'UTC'),
    event_type  LowCardinality(String),
    user_id     String  DEFAULT '',
    payload     String  DEFAULT '{}',
    duration_ms UInt32  CODEC(T64, LZ4)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time)
TTL toDateTime(event_time) + INTERVAL 12 MONTH;
&lt;/span&gt;&lt;span class="no"&gt;SQL
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LowCardinality(String)&lt;/code&gt; for low distinct columns and the &lt;code&gt;T64&lt;/code&gt; codec on the duration give you strong compression for free.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Expose it to a remote app with a Cloudflare Tunnel
&lt;/h2&gt;

&lt;p&gt;This is the part that keeps the database private. Instead of opening a port and firewalling it, &lt;code&gt;cloudflared&lt;/code&gt; makes an &lt;em&gt;outbound&lt;/em&gt; connection to Cloudflare; requests to a hostname get pushed back down that connection to the daemon, which forwards them to &lt;code&gt;localhost:8123&lt;/code&gt; (ClickHouse’s HTTP interface). &lt;strong&gt;Zero inbound ports.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I’d already created a named tunnel and installed the service. The routing is defined in a config file with ingress rules:&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; /etc/cloudflared
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/cloudflared/config.yml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;YAML&lt;/span&gt;&lt;span class="sh"&gt;'
tunnel: &amp;lt;your-tunnel-uuid&amp;gt;
credentials-file: /home/ubuntu/.cloudflared/&amp;lt;your-tunnel-uuid&amp;gt;.json

ingress:
  - hostname: db.example.com
    service: http://localhost:8123
  - service: http_status:404          # catch-all, must be last
&lt;/span&gt;&lt;span class="no"&gt;YAML

&lt;/span&gt;cloudflared tunnel ingress validate    &lt;span class="c"&gt;# should print OK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point the systemd service at that config. Edit the unit:&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 edit &lt;span class="nt"&gt;--full&lt;/span&gt; cloudflared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;ExecStart&lt;/code&gt; to:&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="nt"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/usr/local/bin/cloudflared tunnel --config /etc/cloudflared/config.yml run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reload and restart:&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 restart cloudflared
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; cloudflared &lt;span class="nt"&gt;-n&lt;/span&gt; 20 &lt;span class="nt"&gt;--no-pager&lt;/span&gt;   &lt;span class="c"&gt;# look for "Registered tunnel connection"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Gotcha  &lt;code&gt;--url&lt;/code&gt; silently ignores your ingress rules.&lt;/strong&gt; My service was originally started as &lt;code&gt;cloudflared tunnel run --url http://localhost:8000 &amp;lt;name&amp;gt;&lt;/code&gt;. That &lt;code&gt;--url&lt;/code&gt; flag is single service “quick” mode: it routes &lt;em&gt;every&lt;/em&gt; hostname on the tunnel to that one URL and completely ignores &lt;code&gt;config.yml&lt;/code&gt;. So my database hostname was quietly being sent to port 8000 and returning &lt;code&gt;502 Bad Gateway&lt;/code&gt;. The fix is exactly what’s above drop &lt;code&gt;--url&lt;/code&gt; entirely and use &lt;code&gt;--config ... run&lt;/code&gt; so ingress rules take over.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;Gotcha  the &lt;code&gt;run&lt;/code&gt; subcommand is mandatory.&lt;/strong&gt; &lt;code&gt;cloudflared tunnel --config &amp;lt;file&amp;gt;&lt;/code&gt; without a trailing &lt;code&gt;run&lt;/code&gt; just prints help and exits, and systemd restart loops it. The flag goes &lt;em&gt;before&lt;/em&gt; &lt;code&gt;run&lt;/code&gt;: &lt;code&gt;cloudflared tunnel --config &amp;lt;file&amp;gt; run&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Confirm the origin is healthy from the box itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8123   &lt;span class="c"&gt;# -&amp;gt; "Ok."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  7. Lock the tunnel with Cloudflare Access
&lt;/h2&gt;

&lt;p&gt;Routing to 8123 alone would leave the database open to anyone who knows the URL. The gate is &lt;strong&gt;Cloudflare Access&lt;/strong&gt;: create an Access application on &lt;code&gt;db.example.com&lt;/code&gt; with a &lt;strong&gt;Service Auth&lt;/strong&gt; policy and a &lt;strong&gt;service token&lt;/strong&gt;. Every request must then carry &lt;code&gt;CF-Access-Client-Id&lt;/code&gt; and &lt;code&gt;CF-Access-Client-Secret&lt;/code&gt; headers Cloudflare checks them &lt;em&gt;at its edge&lt;/em&gt; and returns 403 to anything without them, before the request ever reaches your box.&lt;/p&gt;

&lt;p&gt;Your remote app connects like this (env vars):&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;DB_HOST&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;db.example.com&lt;/span&gt;
&lt;span class="py"&gt;DB_PORT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;443&lt;/span&gt;
&lt;span class="py"&gt;DB_SECURE&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;CF_ACCESS_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;service-token-id&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;CF_ACCESS_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;service-token-secret&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;DB_USER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;app_ro&lt;/span&gt;
&lt;span class="py"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;readonly-password&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;End to end test from your laptop  CF token &lt;em&gt;and&lt;/em&gt; read only DB creds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://db.example.com/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"CF-Access-Client-Id: &amp;lt;id&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"CF-Access-Client-Secret: &amp;lt;secret&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-ClickHouse-User: app_ro"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-ClickHouse-Key: &amp;lt;readonly-password&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; &lt;span class="s2"&gt;"SELECT count() FROM metrics.events"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes confirm everything’s wired: missing CF headers → Cloudflare 403; valid headers + creds → a number; a write attempt with &lt;code&gt;app_ro&lt;/code&gt; → ClickHouse “readonly mode” error.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Use &lt;code&gt;X-ClickHouse-User&lt;/code&gt; / &lt;code&gt;X-ClickHouse-Key&lt;/code&gt; headers, not &lt;code&gt;-u&lt;/code&gt; basic auth.&lt;/strong&gt; Basic auth sets the &lt;code&gt;Authorization&lt;/code&gt; header; keeping ClickHouse creds in their own headers avoids any collision with Cloudflare’s &lt;code&gt;CF-Access-*&lt;/code&gt; auth.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;Gotcha the IP allowlist can’t gate the tunnel.&lt;/strong&gt; Because &lt;code&gt;cloudflared&lt;/code&gt; connects to ClickHouse from &lt;code&gt;localhost&lt;/code&gt;, ClickHouse sees &lt;code&gt;127.0.0.1&lt;/code&gt; for &lt;em&gt;all&lt;/em&gt; tunnel traffic. Its &lt;code&gt;&amp;lt;networks&amp;gt;&lt;/code&gt; allowlist therefore can’t distinguish a remote request from a local one  &lt;strong&gt;Cloudflare Access is your access control, not the IP list.&lt;/strong&gt; So: keep the Service Auth policy with no “allow everyone” bypass, give every ClickHouse user a strong password, and hand the remote app only the read only creds.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  8. Automated nightly backups to S3
&lt;/h2&gt;

&lt;p&gt;The last piece: I never want to lose this data. I used &lt;code&gt;clickhouse-backup&lt;/code&gt; with the EC2 instance’s &lt;strong&gt;IAM role&lt;/strong&gt; for S3 auth, so there are no AWS keys sitting on disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  8a. Create an IAM role and attach it
&lt;/h3&gt;

&lt;p&gt;In the AWS console: &lt;strong&gt;IAM → Policies → Create policy → JSON&lt;/strong&gt;:&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&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;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Action"&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="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"s3:DeleteObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"s3:AbortMultipartUpload"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&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;"arn:aws:s3:::my-clickhouse-backups"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-clickhouse-backups/*"&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;Name it, then &lt;strong&gt;IAM → Roles → Create role → AWS service → EC2&lt;/strong&gt;, attach that policy, name the role. Finally &lt;strong&gt;EC2 → your instance → Actions → Security → Modify IAM role&lt;/strong&gt; and attach it. No reboot needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  8b. Install and configure clickhouse backup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /tmp
curl &lt;span class="nt"&gt;-fL&lt;/span&gt; https://github.com/Altinity/clickhouse-backup/releases/latest/download/clickhouse-backup-linux-amd64.tar.gz &lt;span class="nt"&gt;-o&lt;/span&gt; chb.tar.gz
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; chb &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzf&lt;/span&gt; chb.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; chb
&lt;span class="nb"&gt;sudo mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find chb &lt;span class="nt"&gt;-name&lt;/span&gt; clickhouse-backup &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /usr/local/bin/
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x /usr/local/bin/clickhouse-backup
clickhouse-backup &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Config — leaving &lt;code&gt;access_key&lt;/code&gt;/&lt;code&gt;secret_key&lt;/code&gt; out makes it fall back to the IAM role automatically:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'general:\n  remote_storage: s3\n  backups_to_keep_local: 1\n  backups_to_keep_remote: 14\nclickhouse:\n  host: localhost\n  port: 9000\n  username: default\n  password: "your-default-password"\ns3:\n  bucket: my-clickhouse-backups\n  region: us-east-1\n  path: clickhouse/prod\n'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/clickhouse-backup/config.yml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /etc/clickhouse-backup/config.yml
&lt;span class="nb"&gt;sudo &lt;/span&gt;clickhouse-backup print-config    &lt;span class="c"&gt;# confirms it parses&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Gotcha  heredocs and YAML hate a flaky paste.&lt;/strong&gt; My SSH client kept prepending a space to every pasted line. That broke the config two ways: heredocs never terminated (the closing delimiter got indented, so the shell waited forever at the &lt;code&gt;&amp;gt;&lt;/code&gt; prompt), and even when written, the YAML failed with &lt;code&gt;did not find expected key&lt;/code&gt; because root keys weren’t at column 0. &lt;strong&gt;The fix that always works: a single line &lt;code&gt;printf&lt;/code&gt; with &lt;code&gt;\n&lt;/code&gt; baked in&lt;/strong&gt; (above) one logical line can’t be mis indented by a paste. Check with &lt;code&gt;cat -A file&lt;/code&gt;  root keys must be flush left with &lt;code&gt;$&lt;/code&gt; right after and no &lt;code&gt;^I&lt;/code&gt; tabs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Test it manually before trusting the schedule:&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;clickhouse-backup create_remote     &lt;span class="c"&gt;# freeze + upload in one step, auto-timestamped&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;clickhouse-backup list remote        &lt;span class="c"&gt;# should list your backup in S3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Gotcha &lt;code&gt;no EC2 IMDS role found&lt;/code&gt;.&lt;/strong&gt; My first upload created the local backup fine, then failed on the S3 push:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;failed to refresh cached credentials, no EC2 IMDS role found, ... EC2 IMDS ... StatusCode: 404
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;That 404 means no IAM role is attached to the instance  the SDK has nowhere to get credentials. I’d forgotten step 8a. Confirm the role is live:&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sX&lt;/span&gt; PUT &lt;span class="s2"&gt;"http://169.254.169.254/latest/api/token"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-aws-ec2-metadata-token-ttl-seconds: 60"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-aws-ec2-metadata-token:&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://169.254.169.254/latest/meta-data/iam/security-credentials/
&lt;span class="c"&gt;# -&amp;gt; your-role-name  (empty = no role attached)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Attach the role, wait ~30s, re run. If you genuinely can’t use a role, you can put &lt;code&gt;access_key&lt;/code&gt;/&lt;code&gt;secret_key&lt;/code&gt; in the &lt;code&gt;s3:&lt;/code&gt; block instead  but the role is cleaner and rotation free.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  8c. The cron  a fixed &lt;em&gt;local&lt;/em&gt; time
&lt;/h3&gt;

&lt;p&gt;I wanted 07:30 in my own timezone regardless of the box’s UTC clock. Cron supports &lt;code&gt;CRON_TZ&lt;/code&gt;, which spares you any offset math:&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="nv"&gt;CRON_TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Asia/Kolkata
30 7 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; root /usr/local/bin/clickhouse-backup create_remote &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/clickhouse-backup.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that in &lt;code&gt;/etc/cron.d/clickhouse-backup&lt;/code&gt;. &lt;code&gt;create_remote&lt;/code&gt; with no name auto timestamps each backup, and &lt;code&gt;backups_to_keep_remote: 14&lt;/code&gt; prunes to the last 14 automatically no cleanup job needed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Gotcha  no leading whitespace, and no dot in the filename.&lt;/strong&gt; After my flaky paste, &lt;code&gt;cat -A&lt;/code&gt; showed a leading space on both lines. A space before &lt;code&gt;30 7 ...&lt;/code&gt; cron tolerates, but a space before &lt;code&gt;CRON_TZ&lt;/code&gt; means cron may ignore the timezone line and run at 07:30 &lt;strong&gt;UTC&lt;/strong&gt; instead. Strip it: &lt;code&gt;sudo sed -i 's/^[[:space:]]*//' /etc/cron.d/clickhouse-backup&lt;/code&gt;. Also: files in &lt;code&gt;/etc/cron.d/&lt;/code&gt; must &lt;strong&gt;not&lt;/strong&gt; have a dot in the name  cron silently ignores &lt;code&gt;clickhouse-backup.conf&lt;/code&gt;. Name it &lt;code&gt;clickhouse-backup&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Verify:&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;cat&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; /etc/cron.d/clickhouse-backup      &lt;span class="c"&gt;# two flush left lines&lt;/span&gt;
systemctl status cron &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;That’s the whole thing: ClickHouse running on a shared box, bound to localhost, tuned so it plays nice with its neighbours; two scoped users; a Cloudflare Tunnel + Access combo that lets a remote app read the data with zero open ports; and a nightly IAM authenticated backup to S3 with 14 day retention.&lt;/p&gt;

&lt;p&gt;A few things I deliberately left for later (YAGNI until proven otherwise):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Incremental backups&lt;/strong&gt; (&lt;code&gt;-diff-from-remote&lt;/code&gt;) only worth it once full backup time actually hurts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A “backup didn’t run” alert&lt;/strong&gt;  a second cron that pings me if no success line hits the log in ~26h. Worth adding the day the data becomes recovery critical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS between &lt;code&gt;cloudflared&lt;/code&gt; and ClickHouse&lt;/strong&gt;  unnecessary here since that hop is over loopback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The recurring lesson, if there is one: read the error log. Every single failure I hit the boot refusal, the 502, the IMDS 404, the YAML parse error told me exactly what was wrong in its first line. The fixes were small; finding them was just a matter of looking.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>database</category>
      <category>infrastructure</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Everything That Goes Wrong With That Setup (And How I Debugged It)</title>
      <dc:creator>Pratham Chauhan</dc:creator>
      <pubDate>Thu, 09 Jul 2026 08:08:05 +0000</pubDate>
      <link>https://dev.to/prathamchauhan/everything-that-goes-wrong-with-that-setup-and-how-i-debugged-it-2kc1</link>
      <guid>https://dev.to/prathamchauhan/everything-that-goes-wrong-with-that-setup-and-how-i-debugged-it-2kc1</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/prathamchauhan/setting-up-gemini-on-vertex-ai-for-production-a-no-nonsense-walkthrough-22hp"&gt;Part 1&lt;/a&gt;, I walked through setting up Gemini via Vertex AI, from project setup through a keyless EC2 deployment with Workload Identity Federation (WIF). If a few of those terms went by fast, quick recap: a service account is a non-human identity your app logs in as, an IAM role is a labeled bundle of permissions, and WIF is the system that lets AWS vouch for your server's identity to Google without any password changing hands.&lt;/p&gt;

&lt;p&gt;This post is the part nobody puts in the official docs: every single thing that went wrong when I actually did this, in the order I hit it, and what each error really meant. Six distinct bugs, stacked on top of each other. Almost every one of them initially looked like a variation of the same "401 Unauthorized" error, and figuring out that they were six different problems, not one stubborn one, was most of the battle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: A 502 that had nothing to do with Google Cloud
&lt;/h2&gt;

&lt;p&gt;Before I'd even gotten to testing auth, the site returned a 502 Bad Gateway. Easy mistake to make here: jumping straight to "something's wrong with my Vertex AI setup." A 502 just means the web server in front of your app (commonly nginx) sent a request to your app and got no answer back at all. It tells you nothing about why, only that your app isn't there to ask.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 list
pm2 logs &amp;lt;app-name&amp;gt; &lt;span class="nt"&gt;--lines&lt;/span&gt; 200
curl &lt;span class="nt"&gt;-v&lt;/span&gt; http://localhost:3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;curl localhost:3000&lt;/code&gt; doesn't get a response, the problem is the Node process itself, full stop, not anything cloud related. Always rule this out first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: My process manager was silently ignoring my config
&lt;/h2&gt;

&lt;p&gt;The actual cause of that 502: I'd saved my PM2 ecosystem file, which is just a config file that tells PM2 how to start and manage your app, as &lt;code&gt;ecosystem.config.cjs&lt;/code&gt; (correct, since &lt;code&gt;module.exports&lt;/code&gt; is older CommonJS style syntax) but ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 start ecosystem.config.js   &lt;span class="c"&gt;# filename typo, PM2 never loaded it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PM2 didn't throw an error. It just fell back to running the app a different way, which meant none of my environment variables, things like &lt;code&gt;GOOGLE_APPLICATION_CREDENTIALS&lt;/code&gt;, project ID, location, ever reached the process. The app looked alive. It just had none of the config I assumed it had.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 delete &amp;lt;app-name&amp;gt;
pm2 start ecosystem.config.cjs
pm2 &lt;span class="nb"&gt;env &lt;/span&gt;0   &lt;span class="c"&gt;# confirm the vars you expect are actually there&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last command became my go to sanity check for the rest of this whole debugging session, and honestly should be step one any time an app "isn't picking up" an environment variable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #3: Chasing the wrong service entirely
&lt;/h2&gt;

&lt;p&gt;With the app finally up, I got a real, recognizable Google error:&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="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Could not load the default credentials..."&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;Straightforward enough. But a bit later, a completely different looking error showed up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" ...&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;401 - Unauthorized&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the detail that mattered: this isn't a shape Google ever returns. Google's API errors are JSON. Their HTML error pages are branded with Google's actual logo and styling. This bare, unbranded XHTML 401 page is a generic default error document, the kind produced by a totally different piece of infrastructure. I burned real time suspecting a corporate proxy sitting between my server and the internet, then suspecting AWS S3, since the app also uploads images there. Both wrong. Direct &lt;code&gt;curl&lt;/code&gt; tests against Google and S3 came back clean, with real certificates and their own genuine error formats.&lt;/p&gt;

&lt;p&gt;The actual source: &lt;code&gt;http://169.254.169.254/latest/meta-data/iam/security-credentials/&lt;/code&gt;. That address is the AWS EC2 Instance Metadata Service, usually shortened to IMDS. Every AWS EC2 server can quietly ask this internal, invisible to the outside world address "who am I, and what permissions do I currently have," without needing a password to ask.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://169.254.169.254/latest/meta-data/iam/security-credentials/
&lt;span class="c"&gt;# → the exact same XHTML 401 page&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth explaining why a Google library was even talking to an AWS only address. That same &lt;code&gt;169.254.169.254&lt;/code&gt; address is the standard link local metadata address on both AWS and Google Cloud. It's not a coincidence, it's part of a convention both cloud providers adopted. Google's auth library has a built in fallback that checks this exact address when it can't find credentials another way, because on a real Google Cloud server, that's genuinely where instance metadata lives. Run that same library on an EC2 instance instead, and it knocks on the same door, except now AWS is answering, with AWS's rules, not Google's. That collision is the single most confusing part of this entire setup, and the reason a "Google auth error" turned out to actually be an AWS one.&lt;/p&gt;

&lt;p&gt;Takeaway: when an error's shape doesn't match what the service "should" return, trust that mismatch. It's telling you to look elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #4: IMDSv2 vs. an incomplete WIF config
&lt;/h2&gt;

&lt;p&gt;Once I knew it was IMDS, the next question was why it was rejecting the request. My EC2 instance correctly enforced &lt;code&gt;HttpTokens: required&lt;/code&gt;, which means it was running the newer, safer version of the metadata service called IMDSv2. In plain terms, IMDSv1 let absolutely anything on the machine ask the metadata address a question and get an answer back immediately. IMDSv2 adds one extra step: you first have to ask for a short lived "session token" with a separate request, and only then can you use that token to actually ask your question. It exists specifically to block a class of attack where a vulnerability in your app could otherwise be tricked into reading your server's cloud credentials. Disabling it is not the fix.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;google-auth-library&lt;/code&gt; does support IMDSv2, but only if your WIF credential config explicitly tells it to, via one field inside &lt;code&gt;credential_source&lt;/code&gt;, which is the part of the config file describing exactly how to fetch AWS's identity proof:&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="nl"&gt;"credential_source"&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;"environment_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"region_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://169.254.169.254/latest/meta-data/placement/availability-zone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://169.254.169.254/latest/meta-data/iam/security-credentials"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"regional_cred_verification_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&amp;amp;Version=2011-06-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"imdsv2_session_token_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://169.254.169.254/latest/api/token"&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;My config, generated before I'd thought hard about IMDSv2, was missing that one line. Add it, and the library correctly fetches that short lived session token first before making any metadata requests, satisfying IMDSv2 without weakening it.&lt;/p&gt;

&lt;p&gt;(One thing to watch for down the road: there's a known upstream bug where this session token gets cached with only a 300 second lifespan, which can cause intermittent failures on long running processes well after everything looks fixed. If the same error reappears hours or days later, that's the first thing to check, not a regression in your config.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #5: A service account that didn't exist
&lt;/h2&gt;

&lt;p&gt;Auth chain working, AWS identity exchanged for a Google token, and a brand new error appeared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Permission 'aiplatform.endpoints.predict' denied... or it may not exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried granting IAM roles to &lt;code&gt;gemini-prod-runner@my-project.iam.gserviceaccount.com&lt;/code&gt;, per Part 1's instructions, and got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INVALID_ARGUMENT: Service account gemini-prod-runner@... does not exist.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The account in my actual project had drifted to a slightly different name during earlier setup. Worse, my WIF config had no &lt;code&gt;service_account_impersonation_url&lt;/code&gt; field at all. Impersonation, in this context, just means "let identity A borrow identity B's permissions temporarily." Its absence meant my setup wasn't using that pattern in the first place. It was mapping the AWS identity straight to a Google identity of its own, with no service account in between.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam service-accounts list &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /path/to/credentials.json   &lt;span class="c"&gt;# check for service_account_impersonation_url&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: don't assume your WIF setup follows the impersonation pattern just because that's the common example in tutorials, including Part 1's. Read your actual credential config file before granting anything. It tells you exactly which pattern you're on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #6: Binding the role to the wrong shape of identity
&lt;/h2&gt;

&lt;p&gt;The final piece: figuring out what string to actually grant &lt;code&gt;roles/aiplatform.user&lt;/code&gt; to, since no service account was involved. This came down to the provider's attribute mapping, which is just the rule set that tells Google how to translate the raw identity info AWS sends over into something Google understands:&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;attributeMapping&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;attribute.aws_role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assertion.arn.contains('assumed-role')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;assertion.arn"&lt;/span&gt;
  &lt;span class="na"&gt;google.subject&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assertion.arn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick translation of the jargon here. An ARN is AWS's way of uniquely naming any resource, including an identity, written as a long string like &lt;code&gt;arn:aws:sts::123456789:assumed-role/my-role/instance-id&lt;/code&gt;. The "assertion" is the signed proof AWS hands over saying "this is genuinely who's asking." &lt;code&gt;google.subject&lt;/code&gt; is Google's term for the exact identity string it sees.&lt;/p&gt;

&lt;p&gt;I could have bound the role to &lt;code&gt;google.subject&lt;/code&gt;, the full AWS identity string including the specific EC2 instance ID baked into it. That would have worked, but only for that one instance, breaking the moment it got replaced or the app scaled to a second server. Binding to &lt;code&gt;attribute.aws_role&lt;/code&gt; instead, a cleaned up version of that string with the instance specific part stripped out, targets the IAM role generally. That means any current or future instance carrying that role authenticates correctly with no further changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud projects add-iam-policy-binding &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/aws-ec2-pool/attribute.aws_role/arn:aws:sts::AWS_ACCOUNT_ID:assumed-role/ec2-gemini-runner"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/aiplatform.user"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more bit of jargon worth untangling here: &lt;code&gt;principal&lt;/code&gt; versus &lt;code&gt;principalSet&lt;/code&gt;. A principal is one single, specific identity, like one exact instance's session. A principalSet is a whole group of identities that share some attribute, in this case "anyone using this AWS role," no matter which instance they're running on. That's why principalSet was the right choice for something durable.&lt;/p&gt;

&lt;p&gt;That was the last piece. Gemini finally responded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually do differently
&lt;/h2&gt;

&lt;p&gt;If I were setting this up again, knowing what I know now: I'd get the app fully working with a plain service account JSON key first, and only swap to WIF as a deliberate, isolated second step, since debugging two unfamiliar systems at the same time, your app's Vertex AI integration and a federated trust chain between two clouds, is much harder than debugging them one at a time. I'd open the credential config file before assuming anything about impersonation, since it tells you definitively which auth pattern you're on. I'd compare error response shapes, not just status codes, since a 401 from Google, a 401 from AWS IMDS, and a 401 from anything in between all look different once you actually read them side by side. I'd always fully restart my process manager, meaning delete and start fresh rather than just restart, after any environment variable change, and verify with &lt;code&gt;pm2 env&lt;/code&gt;. And I'd follow up every &lt;code&gt;add-iam-policy-binding&lt;/code&gt; command with a &lt;code&gt;get-iam-policy&lt;/code&gt; check, since a typo'd identity name fails loudly but a propagation delay or a wrong project fails silently.&lt;/p&gt;

&lt;p&gt;None of these six bugs were individually hard. Stacked on top of each other, each one disguised as a slightly different flavor of the same 401, they ate a full day. Hopefully Part 1 plus this post saves you most of that day.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to Let a Vercel App Read a Private ClickHouse on EC2 (Using Cloudflare Tunnel)</title>
      <dc:creator>Pratham Chauhan</dc:creator>
      <pubDate>Thu, 02 Jul 2026 10:00:15 +0000</pubDate>
      <link>https://dev.to/prathamchauhan/how-to-let-a-vercel-app-read-a-private-clickhouse-on-ec2-using-cloudflare-tunnel-3kjp</link>
      <guid>https://dev.to/prathamchauhan/how-to-let-a-vercel-app-read-a-private-clickhouse-on-ec2-using-cloudflare-tunnel-3kjp</guid>
      <description>&lt;p&gt;&lt;em&gt;A start to finish walkthrough, with every piece of jargon explained in plain language.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why you might need this
&lt;/h2&gt;

&lt;p&gt;Sooner or later, most teams end up with two things that need to talk to each other&lt;br&gt;
but live in different worlds.&lt;/p&gt;

&lt;p&gt;On one side is a &lt;strong&gt;private backend&lt;/strong&gt;: a database, an internal API, an admin panel,&lt;br&gt;
or some other service running on a server you control (an EC2 box, a machine in your&lt;br&gt;
office, a virtual private cloud). You deliberately keep it locked away from the&lt;br&gt;
public internet, because it holds data that would be dangerous to expose. Locking it&lt;br&gt;
down is the right call for security.&lt;/p&gt;

&lt;p&gt;On the other side is a &lt;strong&gt;program hosted somewhere else&lt;/strong&gt;: an app on Vercel, a&lt;br&gt;
serverless function, a scheduled job, a third party tool, or an AI agent. It needs to&lt;br&gt;
read from that private backend to do its work, but it runs outside your private&lt;br&gt;
network, so it has no way in.&lt;/p&gt;

&lt;p&gt;This is a genuinely awkward gap, and it shows up constantly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dashboard or reporting app on Vercel needs to query a database that only lives
inside your private network.&lt;/li&gt;
&lt;li&gt;A scheduled job or an AI agent needs to pull fresh data every hour from a warehouse
that has no public address.&lt;/li&gt;
&lt;li&gt;A webhook or a partner integration needs to reach an internal service without you
exposing that service to the whole internet.&lt;/li&gt;
&lt;li&gt;You are running something locally or on a home server and want a stable, secure web
address for it without buying a static IP or fighting with your router.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tempting shortcuts all make things worse. Opening a port to the internet turns a&lt;br&gt;
carefully hidden service into a target. Restricting by IP address fails when the&lt;br&gt;
outside program has no fixed address (Vercel and most serverless platforms do not).&lt;br&gt;
Building a custom API server in front of it just moves the exposed door and adds a&lt;br&gt;
second thing to secure and maintain.&lt;/p&gt;

&lt;p&gt;What you actually want is a way for the outside program to reach the private service&lt;br&gt;
that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;never requires opening an incoming port,&lt;/li&gt;
&lt;li&gt;does not depend on the outside program having a fixed address,&lt;/li&gt;
&lt;li&gt;encrypts the traffic and puts real authentication in front of it, and&lt;/li&gt;
&lt;li&gt;costs little or nothing to run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is exactly what a Cloudflare Tunnel gives you, and this guide walks through&lt;br&gt;
setting one up end to end. The specific example is an AI agent on Vercel reading a&lt;br&gt;
ClickHouse database on EC2, but the same pattern works for any private service and any&lt;br&gt;
outside consumer. Once you understand the shape of it, you will reach for it again and&lt;br&gt;
again.&lt;/p&gt;


&lt;h2&gt;
  
  
  The problem, in one sentence
&lt;/h2&gt;

&lt;p&gt;We run a ClickHouse database on an EC2 server that is not reachable from the&lt;br&gt;
public internet, and we need a program hosted on Vercel to read from it safely.&lt;/p&gt;

&lt;p&gt;That sentence has four pieces of jargon. Here is what each one means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ClickHouse&lt;/strong&gt;: a database built for analytics. It stores huge tables of events
(in our case, every tool call and session from our apps) and answers questions
like "how many errors happened in the last hour" very fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EC2&lt;/strong&gt;: a virtual server you rent from Amazon Web Services (AWS). Think of it
as a computer in Amazon's data center that we control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not publicly accessible&lt;/strong&gt;: the server's firewall blocks incoming connections
from the internet. Nothing outside can knock on its door. This is good for
security, but it means our outside program cannot reach the database directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt;: a hosting platform where our program (an AI agent) runs. Vercel
runs code in the cloud, and its servers do not have a fixed address, which
matters later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the puzzle is: the database is deliberately locked away inside a private&lt;br&gt;
network, and the program that needs it lives somewhere else entirely.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why the obvious ideas are the wrong ideas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Just open the database port to the internet."&lt;/strong&gt; You could change the firewall&lt;br&gt;
to allow the world to connect to ClickHouse. But now your database is exposed to&lt;br&gt;
every bot on the internet, protected only by a password. One leaked password or&lt;br&gt;
one unpatched bug and your data is gone. We do not want the database reachable&lt;br&gt;
from the open internet at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Only allow Vercel's IP address through the firewall."&lt;/strong&gt; A firewall can be told&lt;br&gt;
"only accept connections from this specific address." The problem is Vercel does&lt;br&gt;
not give your program a fixed address. Its servers pick from a large, changing&lt;br&gt;
pool of addresses, so there is no single address to allow. This approach does not&lt;br&gt;
work here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Build a small API server in front of the database."&lt;/strong&gt; A common instinct is to&lt;br&gt;
write a little web service (for example in Express, a popular Node.js web&lt;br&gt;
framework) that sits in front of ClickHouse, checks a password, and forwards&lt;br&gt;
queries. The catch: that server still has to accept incoming connections from the&lt;br&gt;
internet, so you have just moved the exposed door from the database to the API,&lt;br&gt;
and now you have a second program to maintain and secure. Unless that API tightly&lt;br&gt;
restricts what queries are allowed, it adds work without adding safety.&lt;/p&gt;

&lt;p&gt;We want something better: a way for the outside program to reach the database&lt;br&gt;
&lt;strong&gt;without opening any incoming door at all&lt;/strong&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  The tool that solves it: Cloudflare Tunnel
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare&lt;/strong&gt; is a company that sits in front of websites to make them faster and&lt;br&gt;
safer. One of its free products is &lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is the key idea, and it is the part most people get backwards.&lt;/p&gt;

&lt;p&gt;A normal setup (a "reverse proxy") works like this: the outside service &lt;strong&gt;dials&lt;br&gt;
in&lt;/strong&gt; to your server. That requires your server to have an open door (an open&lt;br&gt;
port), which is exactly what we are trying to avoid.&lt;/p&gt;

&lt;p&gt;A Tunnel works the opposite way. You run a small program on your server called&lt;br&gt;
&lt;strong&gt;&lt;code&gt;cloudflared&lt;/code&gt;&lt;/strong&gt;. That program &lt;strong&gt;dials out&lt;/strong&gt; to Cloudflare and holds the&lt;br&gt;
connection open. When a request arrives for your service, Cloudflare pushes it&lt;br&gt;
&lt;strong&gt;down that outgoing connection&lt;/strong&gt; to &lt;code&gt;cloudflared&lt;/code&gt;, which then hands it to&lt;br&gt;
ClickHouse over the server's own internal network.&lt;/p&gt;

&lt;p&gt;Why this is safe and clever:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No incoming door is ever opened.&lt;/strong&gt; The connection is outbound only, started by
your own server. Your firewall stays fully closed to the outside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare never learns your server's address.&lt;/strong&gt; You do not register an IP with
anyone. There is nothing to point at, and nothing to attack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You never hand Cloudflare an SSH key or any access to your box.&lt;/strong&gt; The little
program you run reaches out to them, not the other way around.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few more terms you will meet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Port&lt;/strong&gt;: a numbered channel on a server for a specific service. ClickHouse's web
interface listens on port &lt;code&gt;8123&lt;/code&gt;. "Opening a port" means allowing outside
connections to that number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;localhost&lt;/code&gt;&lt;/strong&gt;: a shortcut name a computer uses to mean "myself." If ClickHouse
and &lt;code&gt;cloudflared&lt;/code&gt; run on the same server, &lt;code&gt;cloudflared&lt;/code&gt; reaches the database at
&lt;code&gt;localhost:8123&lt;/code&gt;, which is purely internal and never touches the internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS&lt;/strong&gt; (Domain Name System): the internet's phone book. It turns a name like
&lt;code&gt;ch.test.com&lt;/code&gt; into an address a computer can connect to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nameservers&lt;/strong&gt;: the specific servers that hold the phone book entries for your
domain. Whoever runs your nameservers controls your domain's DNS.&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  What you need before you start
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;An &lt;strong&gt;EC2 server&lt;/strong&gt; where ClickHouse is running, and the ability to log into it
over SSH (the standard way to get a remote terminal on a server).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;domain name&lt;/strong&gt;. Cloudflare Tunnel needs to attach your service to a web
address, and to do that the domain must be managed by Cloudflare. Moving your
main company domain is a big change, so the clean move is to buy a cheap, throwaway
domain just for internal infrastructure (this guide uses &lt;code&gt;test.com&lt;/code&gt;). It
costs a few dollars a year and keeps your real domain untouched.&lt;/li&gt;
&lt;li&gt;A free &lt;strong&gt;Cloudflare account&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Your program on &lt;strong&gt;Vercel&lt;/strong&gt; that will do the reading.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A note on the domain, because it trips people up: Cloudflare's free plan requires&lt;br&gt;
the &lt;strong&gt;whole domain&lt;/strong&gt; to use Cloudflare's nameservers. You cannot hand over just one&lt;br&gt;
subdomain on the free plan (that feature, called "partial setup," is a paid&lt;br&gt;
business feature). This is exactly why a separate throwaway domain is the easy&lt;br&gt;
answer: you move the entire throwaway domain to Cloudflare and never touch your&lt;br&gt;
production domain.&lt;/p&gt;


&lt;h2&gt;
  
  
  Phase 1: Put the domain on Cloudflare
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What we are doing and why:&lt;/strong&gt; we are making Cloudflare the manager of the throwaway&lt;br&gt;
domain's phone book (its DNS), because the Tunnel can only attach a web address to a&lt;br&gt;
domain that Cloudflare controls.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In Cloudflare, click &lt;strong&gt;Add a site&lt;/strong&gt;, type your domain (&lt;code&gt;test.com&lt;/code&gt;), and choose
the &lt;strong&gt;Free&lt;/strong&gt; plan.&lt;/li&gt;
&lt;li&gt;Cloudflare scans the domain's existing records and shows them to you. For a fresh
throwaway domain these are just parking entries from the registrar, and you do not
need to add anything. The one address you will actually use (&lt;code&gt;ch.test.com&lt;/code&gt;) gets
created automatically later, so there is nothing to add by hand here.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Continue to activation&lt;/strong&gt;. Cloudflare gives you two nameserver addresses
(something like &lt;code&gt;dana.ns.cloudflare.com&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Go to your domain registrar (where you bought the domain, for example Namecheap),
find the nameserver setting, switch it from the registrar's default to &lt;strong&gt;Custom&lt;/strong&gt;,
and paste in Cloudflare's two nameservers.&lt;/li&gt;
&lt;li&gt;Wait for the domain to show &lt;strong&gt;Active&lt;/strong&gt; in Cloudflare. This can take a few minutes
and occasionally up to a day. Cloudflare emails you when it is ready.&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Phase 2: Create the Tunnel and get its token
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What we are doing and why:&lt;/strong&gt; we are creating the Tunnel on Cloudflare's side and&lt;br&gt;
getting a &lt;strong&gt;token&lt;/strong&gt;. A token is a long secret string that acts as the Tunnel's&lt;br&gt;
password. The little program on our server will use it to prove it is allowed to&lt;br&gt;
connect. We are using the token method (rather than the browser login method)&lt;br&gt;
because it is simpler on a server that has no web browser.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the Cloudflare dashboard, open &lt;strong&gt;Zero Trust&lt;/strong&gt; (this is Cloudflare's security
product area; it also appears under the name "Cloudflare One").&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Networks&lt;/strong&gt;, then &lt;strong&gt;Tunnels&lt;/strong&gt;, then &lt;strong&gt;Create a tunnel&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Choose the connector type &lt;strong&gt;Cloudflared&lt;/strong&gt; and give the tunnel a name (this guide
uses &lt;code&gt;ch&lt;/code&gt;, short for ClickHouse).&lt;/li&gt;
&lt;li&gt;On the install screen, choose your server's operating system. Ubuntu, a very common
Linux for EC2, is built on &lt;strong&gt;Debian&lt;/strong&gt;, so choose &lt;strong&gt;Debian&lt;/strong&gt;. Then choose the
&lt;strong&gt;architecture&lt;/strong&gt; (the chip type). Run &lt;code&gt;uname -m&lt;/code&gt; on your server to check: &lt;code&gt;x86_64&lt;/code&gt;
means choose &lt;strong&gt;64-bit&lt;/strong&gt;, and &lt;code&gt;aarch64&lt;/code&gt; means choose &lt;strong&gt;arm64&lt;/strong&gt; (used by Amazon's
Graviton servers).&lt;/li&gt;
&lt;li&gt;Cloudflare now shows a set of commands with your token baked in. Keep this tab open.&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Phase 3: Install the connector on your server
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What we are doing and why:&lt;/strong&gt; we are installing &lt;code&gt;cloudflared&lt;/code&gt; (the little program that&lt;br&gt;
holds the outbound connection) and setting it up as a &lt;strong&gt;service&lt;/strong&gt;, which means the&lt;br&gt;
operating system keeps it running in the background and restarts it automatically if&lt;br&gt;
the server reboots.&lt;/p&gt;

&lt;p&gt;Cloudflare shows three command boxes on the install screen. You run the first two and&lt;br&gt;
skip the third.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Log into your EC2 server over SSH.&lt;/li&gt;
&lt;li&gt;Copy the &lt;strong&gt;first&lt;/strong&gt; box ("Install cloudflared") using its copy icon, paste it into
the terminal, and run it. This adds Cloudflare's software source and installs the
program.&lt;/li&gt;
&lt;li&gt;Copy the &lt;strong&gt;second&lt;/strong&gt; box ("Install as service"), paste, and run it. It looks like
&lt;code&gt;sudo cloudflared service install eyJ...&lt;/code&gt; where the long &lt;code&gt;eyJ...&lt;/code&gt; string is your
token. This registers the connection and starts it running in the background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip the third box&lt;/strong&gt; ("Or, run tunnel"). The word "Or" is the giveaway: it is an
alternative that runs in the foreground and stops the moment you close the terminal.
We want the background service, not this.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Check it is running:&lt;br&gt;
You want to see &lt;code&gt;active (running)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status cloudflared
&lt;/code&gt;&lt;/pre&gt;


&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Back in the Cloudflare dashboard, the tunnel's status should turn to &lt;strong&gt;Healthy&lt;/strong&gt;&lt;br&gt;
within a few seconds. Healthy means the outbound connection from your server to&lt;br&gt;
Cloudflare is up. It will still say "No routes" because we have not told it what to&lt;br&gt;
serve yet, which is the next step.&lt;/p&gt;


&lt;h2&gt;
  
  
  Phase 4: Point a web address at ClickHouse
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What we are doing and why:&lt;/strong&gt; the tunnel is connected, but it does not yet know what to&lt;br&gt;
do with requests. We now create a &lt;strong&gt;route&lt;/strong&gt; (older versions of the dashboard call this a&lt;br&gt;
"public hostname") that says "when a request comes in for &lt;code&gt;ch.test.com&lt;/code&gt;, forward it to&lt;br&gt;
ClickHouse at &lt;code&gt;localhost:8123&lt;/code&gt;."&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open your tunnel and go to the &lt;strong&gt;Routes&lt;/strong&gt; tab, then click &lt;strong&gt;Add route&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You are shown four route types. Choose &lt;strong&gt;Published application&lt;/strong&gt;. It means "publish a
local service to the internet at a web address." The other three (Private hostname,
Private CIDR, Workers VPC) require special client software and are not what we need.&lt;/li&gt;
&lt;li&gt;Fill in the form:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subdomain&lt;/strong&gt;: &lt;code&gt;ch&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain&lt;/strong&gt;: &lt;code&gt;test.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Path&lt;/strong&gt;: leave empty (empty means "match every request")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service Type&lt;/strong&gt;: &lt;code&gt;HTTP&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service URL&lt;/strong&gt;: &lt;code&gt;http://localhost:8123&lt;/code&gt; (this is ClickHouse on the same server;
if ClickHouse runs on a different machine inside your private network, use that
machine's private address instead of &lt;code&gt;localhost&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add route&lt;/strong&gt;. Cloudflare automatically creates the DNS record for
&lt;code&gt;ch.test.com&lt;/code&gt;, so you do not have to add it by hand.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Test it&lt;/strong&gt; from your own laptop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"&amp;lt;https://ch.test.com/?query=SELECT%201&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of terms here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;curl&lt;/code&gt;&lt;/strong&gt;: a command line tool for making web requests. It is the quickest way to
check that an address responds.&lt;/li&gt;
&lt;li&gt;The quotes around the address matter. The &lt;code&gt;zsh&lt;/code&gt; shell (the default terminal on modern
Macs) treats a bare &lt;code&gt;?&lt;/code&gt; as a special "match any file" character, and without quotes it
errors with &lt;code&gt;no matches found&lt;/code&gt;. Quoting the address turns off that behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you get back &lt;code&gt;1&lt;/code&gt;, the full path works: your laptop reached Cloudflare, Cloudflare&lt;br&gt;
pushed the request down the tunnel to your server, and &lt;code&gt;cloudflared&lt;/code&gt; handed it to&lt;br&gt;
ClickHouse, which answered &lt;code&gt;SELECT 1&lt;/code&gt; with &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  Phase 5: Give the app a limited, read only database account
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What we are doing and why:&lt;/strong&gt; right now anything hitting the tunnel could run any query&lt;br&gt;
using the database's main account. We create a &lt;strong&gt;separate account with the least power&lt;br&gt;
possible&lt;/strong&gt;: it can only read the two tables our program needs, and it cannot write,&lt;br&gt;
change, or delete anything. This is the principle of "least privilege," and it means a&lt;br&gt;
mistake or a leaked credential can do far less damage.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;readonly&lt;/code&gt; user&lt;/strong&gt;: a database account restricted to read only queries. It cannot
insert, update, delete, or change settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GRANT&lt;/strong&gt;: the SQL command that gives an account permission to do a specific thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Least privilege&lt;/strong&gt;: give an account exactly the access it needs and nothing more.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;On the server, open the ClickHouse client. Creating accounts requires an&lt;br&gt;
&lt;strong&gt;administrator&lt;/strong&gt; account (usually called &lt;code&gt;default&lt;/code&gt;), not an ordinary app account. If&lt;br&gt;
you try as a limited user you will see an "ACCESS_DENIED" error about needing the&lt;br&gt;
"CREATE USER" grant. Connect as the admin:&lt;br&gt;
&lt;/p&gt;

&lt;pre class="highlight shell"&gt;&lt;code&gt;clickhouse-client &lt;span class="nt"&gt;-u&lt;/span&gt; default
&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;(Add &lt;code&gt;--password 'yourpassword'&lt;/code&gt; if the admin account has one.)&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;First generate a strong random password and save it somewhere safe:&lt;br&gt;
&lt;/p&gt;

&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 24
&lt;/code&gt;&lt;/pre&gt;


&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Create the read only account and grant it read access to just the two tables. Note&lt;br&gt;
that our tables live in a database named &lt;code&gt;new_database&lt;/code&gt;, so the grants name&lt;br&gt;
&lt;code&gt;new_database.your_table_name&lt;/code&gt; and &lt;code&gt;new_databse.your_other_table_name&lt;/code&gt;. If your tables live&lt;br&gt;
somewhere else, change the database name to match.&lt;br&gt;
&lt;/p&gt;

&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="s1"&gt;'PASTE_THE_GENERATED_PASSWORD'&lt;/span&gt; &lt;span class="n"&gt;SETTINGS&lt;/span&gt; &lt;span class="n"&gt;readonly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;new_database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;your_table_name&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;new_databse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;your_other_table_name&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;QUOTA&lt;/span&gt; &lt;span class="n"&gt;agent_ro_q&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="k"&gt;MAX&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result_rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50000000&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The last line is a &lt;strong&gt;quota&lt;/strong&gt;: a safety limit so a runaway program cannot hammer the&lt;br&gt;
database with unlimited queries.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Test the new account through the tunnel. The address includes &lt;code&gt;?database=new_database&lt;/code&gt;&lt;br&gt;
so ClickHouse knows which database to look in (our program sends this automatically):&lt;br&gt;
&lt;/p&gt;

&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-ClickHouse-User: agent_ro"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-ClickHouse-Key: PASTE_THE_GENERATED_PASSWORD"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&amp;lt;https://ch.test.com/?database=new_database&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; &lt;span class="s2"&gt;"SELECT count() FROM your_table_name FINAL FORMAT JSON"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;You should get back a small block of JSON with a count.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Confirm the read only lock actually works. This next command tries to create a table&lt;br&gt;
and &lt;strong&gt;should be rejected&lt;/strong&gt;, which is exactly what we want:&lt;br&gt;
&lt;/p&gt;

&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-ClickHouse-User: agent_ro"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-ClickHouse-Key: PASTE_THE_GENERATED_PASSWORD"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&amp;lt;https://ch.test.com/?database=new_database&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; &lt;span class="s2"&gt;"CREATE TABLE x (a Int) ENGINE=Memory"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two small things that commonly go wrong here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Getting the database name wrong, or forgetting &lt;code&gt;?database=new_database&lt;/code&gt;, produces an
"UNKNOWN_TABLE" error because ClickHouse looks in the wrong place.&lt;/li&gt;
&lt;li&gt;A typo like &lt;code&gt;?databse=new_database&lt;/code&gt; produces an "UNKNOWN_SETTING" error because ClickHouse
reads the misspelled word as a setting name.&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Phase 6: Add a second lock with a Cloudflare Access service token
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What we are doing and why:&lt;/strong&gt; the database password is one lock. We add a second,&lt;br&gt;
independent lock in front of the web address itself, so that even reaching ClickHouse's&lt;br&gt;
front door requires a separate secret. This is &lt;strong&gt;defense in depth&lt;/strong&gt;: two locks, so one&lt;br&gt;
failing does not expose everything.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Access&lt;/strong&gt;: a Cloudflare feature that stands in front of a web address and
refuses anyone who cannot prove they are allowed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service token&lt;/strong&gt;: a machine to machine credential, made of a &lt;strong&gt;Client ID&lt;/strong&gt; and a
&lt;strong&gt;Client Secret&lt;/strong&gt; (a public name and a private password, roughly). Automated programs
send these as two request headers to get through Access. A "header" is a small labeled
piece of extra information attached to a web request.&lt;/li&gt;
&lt;li&gt;In &lt;strong&gt;Zero Trust&lt;/strong&gt;, go to &lt;strong&gt;Access controls&lt;/strong&gt;, then &lt;strong&gt;Service credentials&lt;/strong&gt;, then
&lt;strong&gt;Service Tokens&lt;/strong&gt;, and click &lt;strong&gt;Create Service Token&lt;/strong&gt;. Name it (for example
&lt;code&gt;your-agent&lt;/code&gt;) and generate it. Copy the &lt;strong&gt;Client ID&lt;/strong&gt; and &lt;strong&gt;Client Secret&lt;/strong&gt; now,
because the secret is shown only once.&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Access controls&lt;/strong&gt;, then &lt;strong&gt;Applications&lt;/strong&gt;, then &lt;strong&gt;Add an application&lt;/strong&gt;, and
choose &lt;strong&gt;Self-hosted&lt;/strong&gt;. Set the application's hostname to &lt;code&gt;ch.test.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add a policy with the action &lt;strong&gt;Service Auth&lt;/strong&gt; (this specifically means "let approved
machines in without a human login screen"). Under &lt;strong&gt;Include&lt;/strong&gt;, choose &lt;strong&gt;Service
Token&lt;/strong&gt; and select the token you just created. Save the policy and the application.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Verify the lock. Without the token, the request should now be blocked:&lt;br&gt;
With both the token headers and the database credentials, it should succeed:&lt;br&gt;
&lt;/p&gt;

&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-so&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;https://ch.test.com/?query=SELECT%201&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;    curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"CF-Access-Client-Id: YOUR_CLIENT_ID"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"CF-Access-Client-Secret: YOUR_CLIENT_SECRET"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-ClickHouse-User: agent_ro"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-ClickHouse-Key:YOUR_DB_PASSWORD"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="s2"&gt;"&amp;lt;https://ch.test.com/?database=new_database&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="nt"&gt;--data-binary&lt;/span&gt; &lt;span class="s2"&gt;"SELECT 1 FORMAT JSON"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If the first is blocked and the second returns &lt;code&gt;1&lt;/code&gt;, you now have two independent locks&lt;br&gt;
in place, and zero open incoming ports on your server.&lt;/p&gt;


&lt;h2&gt;
  
  
  Phase 7: Point the Vercel program at the database
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What we are doing and why:&lt;/strong&gt; the program on Vercel needs to know the address, the&lt;br&gt;
database account, and the Access token. These are supplied as &lt;strong&gt;environment variables&lt;/strong&gt;,&lt;br&gt;
which are named settings you give a program without writing them into its code (so&lt;br&gt;
secrets never live in the source).&lt;/p&gt;

&lt;p&gt;Set these on the Vercel project (in Settings, under Environment 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;CLICKHOUSE_HOST&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ch.test.com&lt;/span&gt;
&lt;span class="py"&gt;CLICKHOUSE_PORT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;443&lt;/span&gt;
&lt;span class="py"&gt;CLICKHOUSE_SECURE&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;CLICKHOUSE_DATABASE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;new_database&lt;/span&gt;
&lt;span class="py"&gt;CLICKHOUSE_USERNAME&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;agent_ro&lt;/span&gt;
&lt;span class="py"&gt;CLICKHOUSE_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your_database_password&lt;/span&gt;
&lt;span class="py"&gt;CF_ACCESS_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your_client_id&lt;/span&gt;
&lt;span class="py"&gt;CF_ACCESS_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your_client_secret&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on the values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Port &lt;code&gt;443&lt;/code&gt;&lt;/strong&gt; with &lt;strong&gt;&lt;code&gt;CLICKHOUSE_SECURE=true&lt;/code&gt;&lt;/strong&gt;: 443 is the standard port for secure
web traffic (HTTPS). Cloudflare serves your tunnel address over HTTPS, so the program
connects on 443 with encryption on, even though ClickHouse itself is plain HTTP on 8123
behind the tunnel.&lt;/li&gt;
&lt;li&gt;The two &lt;code&gt;CF_ACCESS_*&lt;/code&gt; values are what get the program past the Access lock from Phase 6.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Redeploy the Vercel app so the new settings take effect, then run whatever triggers the&lt;br&gt;
program to read ClickHouse. If it returns data instead of a connection error, the whole&lt;br&gt;
chain is working.&lt;/p&gt;


&lt;h2&gt;
  
  
  A quick security checklist before you call it done
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Rotate any secret that has touched a chat window, a screenshot, or a shared note.&lt;/strong&gt;&lt;br&gt;
If you pasted the database password anywhere while setting up, change it:&lt;br&gt;
and update the Vercel environment variable to match.&lt;br&gt;
&lt;/p&gt;

&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="s1"&gt;'a_new_password'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep the account read only.&lt;/strong&gt; Do not grant it more than the tables it needs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Leave the server's incoming firewall closed.&lt;/strong&gt; The whole point of the tunnel is that&lt;br&gt;
you never need to open a port. If you did open one earlier to test, close it again.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  The one gotcha to know about
&lt;/h2&gt;

&lt;p&gt;If the tunnel refuses to turn "Healthy," the usual cause is that your server's&lt;br&gt;
&lt;strong&gt;outbound&lt;/strong&gt; internet access is blocked. &lt;code&gt;cloudflared&lt;/code&gt; prefers a fast protocol called&lt;br&gt;
QUIC over UDP port &lt;code&gt;7844&lt;/code&gt;. If your network blocks UDP, force it to use standard HTTPS&lt;br&gt;
instead: in the tunnel's configuration set the connector protocol to &lt;code&gt;http2&lt;/code&gt;. You still&lt;br&gt;
never open any incoming port; this only changes how the outgoing connection is made.&lt;/p&gt;




&lt;h2&gt;
  
  
  The whole thing in one picture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your laptop / the Vercel app
        |
        v  (request to &amp;lt;https://ch.test.com&amp;gt;, carrying the Access token + DB login)
   Cloudflare edge
        |
        |  Cloudflare Access checks the service token (lock #1)
        |
        v  pushes the request DOWN the connection your server opened
   cloudflared  (running on your EC2 server, connection was outbound only)
        |
        v  &amp;lt;http://localhost:8123&amp;gt;, staying inside the private network
   ClickHouse  (checks the read only account + password, lock #2)
        |
        v
     answer travels back the same way
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No incoming port was ever opened. The database was never exposed to the internet. Two&lt;br&gt;
independent locks guard the path, and the program on Vercel reads exactly the two tables&lt;br&gt;
it is allowed to, and nothing else.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>database</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Setting Up Gemini on Vertex AI for Production: A No-Nonsense Walkthrough</title>
      <dc:creator>Pratham Chauhan</dc:creator>
      <pubDate>Tue, 30 Jun 2026 09:51:39 +0000</pubDate>
      <link>https://dev.to/prathamchauhan/setting-up-gemini-on-vertex-ai-for-production-a-no-nonsense-walkthrough-22hp</link>
      <guid>https://dev.to/prathamchauhan/setting-up-gemini-on-vertex-ai-for-production-a-no-nonsense-walkthrough-22hp</guid>
      <description>&lt;p&gt;If you want to call Gemini through Google Cloud's Vertex AI from a real production environment, not just a local script, there's a specific order of operations that saves you from a lot of pain later. This is that walkthrough: project setup, a properly scoped service account, local testing, and finally getting it running securely on an EC2 instance, including the keyless Workload Identity Federation (WIF) path.&lt;/p&gt;

&lt;p&gt;You might be wondering why bother with all this instead of just grabbing an API key from AI Studio and dropping it into an environment variable. Honestly, for a weekend project, do that. But a raw API key is a single static string that grants full access to whatever it's scoped to, forever, until you remember to revoke it. If it ends up in a public repo, a client side bundle, or a log file, anyone holding it can run up your bill or worse. The setup in this post trades a bit of upfront complexity for something much safer: identity based access that can be scoped down to exactly one permission, rotated without touching your app code, and in the WIF case, never even exists as a file you could accidentally leak. It's the difference between handing someone a house key and giving them a temporary badge that only opens one door and expires on its own.&lt;/p&gt;

&lt;p&gt;Before diving in, a couple of terms that will come up a lot. An IAM role is just a labeled bundle of permissions, like a job title that comes with a fixed list of things you're allowed to do. A service account is not a human user. Think of it as a robot identity that your app logs in as, instead of a person typing a password. We'll use both throughout.&lt;/p&gt;

&lt;p&gt;Part 2 covers everything that goes wrong if you skip a step or fat finger a detail here, and trust me, there's a lot that can go wrong. But first, let's do it right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Pick your project and enable the right API
&lt;/h2&gt;

&lt;p&gt;Open the Google Cloud Console and confirm you're in the correct project (not just "a" project, since billing and permissions are scoped per project, and it's easy to set things up in the wrong one if you have several).&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="nv"&gt;PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-gcp-project-id"&lt;/span&gt;
&lt;span class="nv"&gt;LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"us-central1"&lt;/span&gt;
gcloud config &lt;span class="nb"&gt;set &lt;/span&gt;project &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API you want is &lt;code&gt;aiplatform.googleapis.com&lt;/code&gt;, labeled "Vertex AI API" in the console. An API here just means a specific Google Cloud service you have to switch on before you can use it. There's a deceptively similar sounding one called Vertex AI Search for commerce (&lt;code&gt;retail.googleapis.com&lt;/code&gt;) that has nothing to do with calling Gemini models. Enable the right one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud services &lt;span class="nb"&gt;enable &lt;/span&gt;aiplatform.googleapis.com
gcloud services list &lt;span class="nt"&gt;--enabled&lt;/span&gt; &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"aiplatform.googleapis.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vertex AI locations aren't inherited from your project. You choose a region explicitly, meaning the physical data center area where your requests get processed. &lt;code&gt;us-central1&lt;/code&gt; is a safe, well supported default. Whatever you pick, use the same value everywhere: local testing, EC2 env vars, your app code. Mismatched regions are a surprisingly common source of "it works locally but not in prod."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Set a budget before you test anything
&lt;/h2&gt;

&lt;p&gt;Before your first API call, set up a budget alert. Go to Billing, then Budgets &amp;amp; alerts, then Create budget. Scope it to this specific project, and set a monthly amount you're comfortable with. A hundred dollars is a reasonable starting point. Add alert thresholds at every 10% so you get early warning, not just a surprise at the end of the month.&lt;/p&gt;

&lt;p&gt;One important caveat: budgets are alerts, not hard stops. Google Cloud won't automatically cut you off at your limit. If you need an actual ceiling, build it into your own backend. Check estimated monthly spend before each Gemini call and reject the request if you're over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monthlySpendUsd&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Monthly Gemini budget reached&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is more reliable than a billing disable webhook, because it stops the request, not the project, which matters if this project hosts anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Create a least privilege service account
&lt;/h2&gt;

&lt;p&gt;"Least privilege" just means giving something only the exact permissions it needs to do its job, nothing extra "just in case." Resist the urge to reuse an existing service account or grant Editor or Owner, which are broad, all access roles. Create a fresh service account dedicated to this one purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IAM &amp;amp; Admin → Service Accounts → Create service account
Name: gemini-prod-runner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then grant it exactly one role, &lt;code&gt;roles/aiplatform.user&lt;/code&gt; (shown as "Vertex AI User" in the console). This single role covers everything needed to call Gemini models. It doesn't need Owner, Editor, or any billing or admin permissions, so even if this identity were somehow compromised, the blast radius is small: someone could call Gemini on your dime, but they couldn't touch your other cloud resources or billing settings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud projects add-iam-policy-binding &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:gemini-prod-runner@&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.iam.gserviceaccount.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/aiplatform.user"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Test locally before touching any servers
&lt;/h2&gt;

&lt;p&gt;For local development, Application Default Credentials, usually shortened to ADC, are the path of least resistance. ADC is just Google's term for "let the command line tool log you in once, then every script on this machine can quietly reuse that login" instead of you managing key files by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud auth application-default login
gcloud auth application-default set-quota-project &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second command matters more than it looks. It tells Google which project to bill your test requests against. Skip it and you'll get vague "quota exceeded" or "API not enabled" errors that have nothing to do with quotas or the API.&lt;/p&gt;

&lt;p&gt;Set your environment:&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;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_CLOUD_PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_CLOUD_LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"us-central1"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_GENAI_USE_VERTEXAI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a minimal test script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GoogleGenAI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@google/genai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GoogleGenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;vertexai&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;project&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;GOOGLE_CLOUD_PROJECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;location&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;GOOGLE_CLOUD_LOCATION&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;us-central1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gemini-2.5-flash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Say hello in one sentence.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&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'd rather test with the exact kind of credentials you'll use in production, generate a service account key, which is just a downloadable JSON file containing a long lived password for that robot identity. This is fine for local testing only (more on why not to ship this to prod below):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam service-accounts keys create ~/secrets/gemini-prod-runner.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--iam-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gemini-prod-runner@&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.iam.gserviceaccount.com
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/secrets/gemini-prod-runner.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_APPLICATION_CREDENTIALS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/secrets/gemini-prod-runner.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;secrets/&lt;/code&gt;, &lt;code&gt;.env&lt;/code&gt;, and anything matching &lt;code&gt;*credentials*&lt;/code&gt; or &lt;code&gt;*service-account*&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt; immediately. Don't wait until after the first commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Deploying to EC2, two paths
&lt;/h2&gt;

&lt;p&gt;You have two reasonable options here, and which one you pick is a real tradeoff, not just a "more secure equals always better" decision.&lt;/p&gt;

&lt;p&gt;Option A is putting that service account JSON key file on the server's disk. Copy the key file to the instance, point &lt;code&gt;GOOGLE_APPLICATION_CREDENTIALS&lt;/code&gt; at it, and you're done. Fast to set up, easy to debug, and perfectly fine for getting a first deployment working. The tradeoff is that it's a long lived static secret sitting on a real machine. If it leaks, it's valid until you manually revoke it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scp ~/secrets/gemini-prod-runner.json ubuntu@&amp;lt;EC2_IP&amp;gt;:/home/ubuntu/gemini-prod-runner.json
ssh ubuntu@&amp;lt;EC2_IP&amp;gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;600 /home/ubuntu/gemini-prod-runner.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Option B is Workload Identity Federation, or WIF. In plain terms, WIF lets two clouds vouch for each other without ever sharing a password. AWS already knows, with certainty, which EC2 instance is making a request. WIF lets Google trust that AWS issued vouching instead of asking for a Google specific secret. No Google key ever touches the instance. Instead, AWS proves the instance's identity, and Google exchanges that proof for a short lived token that expires on its own, on demand, every time. More setup work, but nothing sitting on disk to rotate or leak.&lt;/p&gt;

&lt;p&gt;If this is your first time deploying this app, I'd genuinely recommend starting with Option A, confirming everything else works end to end, and then swapping to WIF as an isolated second step. Debugging two unfamiliar systems at once, your app's Vertex AI integration and a federated trust chain between two clouds, is much harder than debugging them one at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Setting up WIF properly
&lt;/h2&gt;

&lt;p&gt;This is the part with the most moving pieces, so go slowly, and here are the building blocks in plain language before the steps.&lt;/p&gt;

&lt;p&gt;An IAM role on the AWS side (yes, AWS has its own separate concept also called a role, easy to confuse with Google's IAM role) is what gives your EC2 instance a verifiable identity card. It's not about granting AWS permissions here, it's purely "this machine is who it says it is."&lt;/p&gt;

&lt;p&gt;A Workload Identity Pool on the Google side is basically a waiting room where Google agrees to listen to identities from somewhere outside Google, like AWS. A provider inside that pool is the specific configuration that says "and here's exactly how to verify an AWS identity, and here's which AWS account I trust." A principal is Google's general word for "the identity asking for access," whether that's a person, a service account, or in this case, an AWS role being recognized through the pool.&lt;/p&gt;

&lt;p&gt;On the AWS side, create that IAM role for EC2. It doesn't need any special AWS permissions, since it exists purely to give the instance a verifiable identity, not to grant AWS side access to anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IAM → Roles → Create role → Trusted entity: AWS service → Use case: EC2
Name: ec2-gemini-runner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attach it to your running instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EC2 → Instances → select instance → Actions → Security → Modify IAM role
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the Google side, create the Workload Identity Pool and an AWS provider inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IAM &amp;amp; Admin → Workload Identity Federation → Create pool
Pool ID: aws-ec2-pool
Provider type: AWS
Provider ID: aws-ec2-provider
AWS account ID: &amp;lt;your AWS account ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then grant that pool's identity permission to act as your service account, or grant the role directly to the AWS identity as its own principal. Google supports both patterns, and which one your setup uses depends on how you configure the binding (a binding is just the rule that says "this identity gets this permission"). The console's "Connected service accounts" flow under your pool will show you which path you're on. Either way, the end result needs to be that this specific AWS role can obtain a Google access token scoped to call Vertex AI.&lt;/p&gt;

&lt;p&gt;Download the generated credential configuration file and place it on the 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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_APPLICATION_CREDENTIALS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/home/ubuntu/gcp-wif-credential.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This file is not a private key. It's a small JSON document of instructions telling Google's auth library how to go ask AWS who it's talking to. Worth opening it once and reading it, since knowing its shape will save you real time if something goes wrong later (Part 2 has a lot more on this).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Wire it into your process manager
&lt;/h2&gt;

&lt;p&gt;Whatever you use to run the app in production, PM2, systemd, Docker, make sure these environment variables actually reach the running process, not just your shell:&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="nv"&gt;GOOGLE_CLOUD_PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-project-id
&lt;span class="nv"&gt;GOOGLE_CLOUD_LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1
&lt;span class="nv"&gt;GOOGLE_GENAI_USE_VERTEXAI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_APPLICATION_CREDENTIALS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/home/ubuntu/gcp-wif-credential.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A systemd unit, for reference:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ubuntu&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/ubuntu/my-app&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"GOOGLE_CLOUD_PROJECT=your-project-id"&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"GOOGLE_CLOUD_LOCATION=us-central1"&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"GOOGLE_GENAI_USE_VERTEXAI=true"&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"GOOGLE_APPLICATION_CREDENTIALS=/home/ubuntu/gcp-wif-credential.json"&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/npm start&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pre-production checklist
&lt;/h2&gt;

&lt;p&gt;A quick list worth running through before calling this done. The correct API, &lt;code&gt;aiplatform.googleapis.com&lt;/code&gt; and not &lt;code&gt;retail.googleapis.com&lt;/code&gt;, is enabled. A budget with staged alerts exists. The service account has &lt;code&gt;roles/aiplatform.user&lt;/code&gt; only, nothing broader. Any key files are outside the repo, &lt;code&gt;chmod 600&lt;/code&gt;, and &lt;code&gt;.gitignore&lt;/code&gt;'d. On EC2, you've picked one auth path deliberately rather than half configuring both. And your application has its own spend guard rather than relying solely on Google's billing alerts.&lt;/p&gt;

&lt;p&gt;That's the setup. If everything above goes exactly to plan, you're done. If it doesn't, and there are a surprising number of ways it doesn't, Part 2 walks through every failure mode I personally hit, in the order I hit them, and how to actually diagnose each one instead of guessing.&lt;/p&gt;

</description>
      <category>gemini</category>
      <category>google</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
