Run Gemini CLI on a headless VPS
Run Google's Gemini CLI on a headless VPS: a current Node, a no-sudo global install, browserless API-key auth, and tmux so long tasks survive SSH drops.
What you are building
An always-on Gemini CLI on a server you own, reachable over SSH, running long agent tasks that keep working after you close the laptop. The install is three commands. What takes work is everything that assumes a desktop: Google's CLI wants to open a browser to log you in, and your server does not have one. So most of this guide is the headless path — a current Node the distro will not give you, a global npm install that does not need root, browserless auth with an API key you keep out of your shell history, and tmux so a dropped SSH session does not take a running task with it.
Gemini CLI is an open-source (Apache-2.0) Node program (@google/gemini-cli) that talks to Google's Gemini models and can read and write files, run shell commands, and drive tools in the working directory. On a VPS it is a small, always-available agent you can leave working — which is why the account it runs as, and the credentials sitting on the box, matter more than any single setting here.
Prerequisites and the honest gotchas
- A fresh Ubuntu 24.04 KVM VPS with root or sudo. Any KVM plan works; the CLI itself is light, a few hundred MB of RAM at rest.
- Node.js 20 or newer. This is the one hard version floor, and the distro package is below it — see the next section.
- Outbound HTTPS (port 443) to Google's APIs. No inbound ports are needed; this is a client, not a server, so you open no firewall hole for it.
- A way to authenticate that does not need a browser on the server: either a Gemini API key from Google AI Studio, or an SSH tunnel back to a browser on your own machine. The API-key path is the one that scales to scripts and unattended runs.
- Docker or Podman, only if you want the
--sandboxisolation. Optional, covered near the end.
The gotcha that catches everyone: the friendly gemini first-run login flow is built for a desktop. It tries to open a browser and, on a headless box, either fails or hands you a link that does not work. Decide the auth path before you start.
Node: the distro package is too old
Ubuntu 24.04 ships Node 18.19.1 in its own repositories, paired with npm 9.2.0. Gemini CLI's package.json declares engines: { node: ">=20" }, and npm does not hard-stop a mismatch by default — it installs anyway and prints a warning that names the gap:
npm WARN EBADENGINE Unsupported engine {
npm WARN EBADENGINE package: '@google/[email protected]',
npm WARN EBADENGINE required: { node: '>=20' },
npm WARN EBADENGINE current: { node: 'v18.19.1', npm: '9.2.0' }
npm WARN EBADENGINE }
Push past that warning and the CLI runs on an unsupported runtime, where it misbehaves or crashes the moment it reaches a Node 20+ API it expects to exist. Node 18 also reached end-of-life in April 2025, so it is a dead end either way. Install a current LTS before you install the CLI. The two clean routes are NodeSource (a system-wide signed apt repo) or nvm (a per-user version manager). Pick one.
NodeSource, if you want Node available to every user on the box:
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version
node --version must print v20.x or higher — v24.x is the current active LTS. Check the NodeSource page for the current setup script; the setup_24.x in the URL is the line to bump when a newer LTS lands.
nvm, if you would rather keep Node inside one user's home and never touch it with sudo:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
node --version
nvm has a real advantage for this job: it installs Node and its global packages under ~/.nvm, so the global-install permission problem in the next section simply never happens. If you go the nvm route, you can skip the npm-prefix step.
Install the CLI without sudo npm -g
The tempting command is sudo npm install -g @google/gemini-cli. Do not. A root-owned global prefix produces permission errors on every later install and leaves root-owned files in your npm cache that bite months from now. Run a plain (no-sudo) npm install -g against a system Node and you get the other failure:
npm error code EACCES
npm error syscall mkdir
npm error path /usr/lib/node_modules/@google
npm error errno -13
npm error Error: EACCES: permission denied, mkdir '/usr/lib/node_modules/@google'
That is npm trying to write into /usr/lib, which your user cannot. The fix is not sudo — it is to point npm's global prefix at your home directory so global installs land somewhere you own:
mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
npm install -g @google/gemini-cli
gemini --version
~/.bashrc, not ~/.profile, is deliberate: tmux — which you will run the CLI inside two sections from now — starts a non-login shell that reads ~/.bashrc and skips ~/.profile, so a PATH line in the wrong file leaves gemini invisible in exactly the place you need it. gemini --version printing a version number is the whole test. If you instead get gemini: command not found, your PATH export did not take — see the failure modes. On nvm, skip the prefix lines entirely: it already installs globals under your home.
If you ran sudo npm at some earlier point and now see Your cache folder contains root-owned files, repair it once with sudo chown -R $(id -u):$(id -g) ~/.npm.
The headless auth problem, and how to get past it
Run gemini interactively the first time and it offers to log you in with your Google account. On a desktop that opens a browser tab. On a headless VPS there is no browser, so the flow either prints a localhost URL it expects you to open, or fails outright with something like:
Failed to open browser. Please visit the following URL to authorize:
https://accounts.google.com/o/oauth2/v2/auth?...&redirect_uri=http://localhost:PORT
The trap is the redirect_uri=http://localhost:PORT. Even if you open that URL on your laptop and approve it, Google redirects to http://localhost:PORT — localhost on the server, a port nothing on your laptop can reach. The login never completes.
There are two honest ways through.
The first is an API key, and it is the right default for a server. Create a key in Google AI Studio (aistudio.google.com) and hand it to the CLI as an environment variable; it reads GEMINI_API_KEY and skips the browser flow entirely. Now the "keep it out of history and world-readable files" part. Do not type export GEMINI_API_KEY=AIza... at the prompt — it lands in ~/.bash_history in cleartext, and do not put it in a file others can read. Write it to a mode-600 file the shell sources at start:
umask 077
printf 'export GEMINI_API_KEY=%s\n' 'AIzaSyYOUR_KEY_HERE' > ~/.gemini_env
chmod 600 ~/.gemini_env
echo '[ -f ~/.gemini_env ] && . ~/.gemini_env' >> ~/.bashrc
source ~/.bashrc
chmod 600 means only your user can read the file. Confirm the key reached the environment with printenv GEMINI_API_KEY; if that prints nothing, the CLI falls back to the browser flow and fails. It also reads a .env file in ~/.gemini/ if you prefer that layout — same rule, so chmod 600 ~/.gemini/.env.
The second way keeps the personal-Google-account login (and its free tier) by tunnelling the OAuth callback back to your laptop. The catch is that the CLI's loopback server binds a random port each run, so there is nothing stable to forward unless you pin it first with the OAUTH_CALLBACK_PORT environment variable, then forward exactly that port:
# from your laptop, forward the callback port into the SSH session:
ssh -L 8085:localhost:8085 user@your-server
# then, on the server, pin the callback to the same port and start the CLI:
export OAUTH_CALLBACK_PORT=8085
gemini
The CLI cannot open a browser, so it prints the auth URL; open it in your laptop browser, approve, and when Google redirects to http://localhost:8085/... the SSH forward carries it to the loopback server on the VPS and the login completes. Leave the port unpinned and it lands on a fresh random port every run, which no ssh -L set up in advance can catch. It works, but it needs you sitting at a browser, so it is no good for scripts. For anything you leave running, use the API key.
For Vertex AI or a Google Cloud project instead of AI Studio, set GOOGLE_API_KEY together with GOOGLE_GENAI_USE_VERTEXAI=true, or GOOGLE_CLOUD_PROJECT for a Code Assist licence — same environment-variable discipline, same mode-600 file.
Run it inside tmux so a dropped SSH session does not kill it
A gemini process you launch straight from your SSH shell is a child of that shell. Lose the connection — closed laptop, dropped Wi-Fi, an idle timeout — and sshd tears down the pseudo-terminal, the shell gets SIGHUP, and it hangs up on the CLI in turn. A task ten minutes into editing files dies with it, and on reconnect there is no process to recover.
tmux fixes this by owning the shell instead of sshd owning it. This is the same pattern as running an AI coding agent on a remote VPS inside tmux, and it works identically here:
sudo apt install -y tmux
tmux new -A -s gemini
# inside the session:
gemini
# detach with Ctrl-b then d — the task keeps running
# reconnect later from any machine:
tmux attach -t gemini
tmux new -A -s gemini attaches to a session named gemini if it exists and creates it if it does not, so it is the one command to run right after every login. The shell inside belongs to the detached tmux server, not to your SSH session, so dropping the connection leaves the CLI working. Reconnect, attach, and you are back in the same scrollback.
For non-interactive, scripted runs, Gemini CLI has a headless mode: gemini -p "summarise the failing tests in this repo" prints an answer and exits, and --output-format json gives machine-readable output to pipe elsewhere. Headless mode with an API key is exactly what you want inside a tmux session running a long batch job, or fired from a cron entry — with one caveat: a cron job sources none of your login files, so give the crontab line its own GEMINI_API_KEY (or have the command source ~/.gemini_env), or the CLI falls back to the browser flow and fails.
Sandboxing and permissions on a box that also runs production
An agent with shell access is a shell. Gemini CLI can run commands, and by default it asks before each risky one — but people reach for --yolo (auto-approve every tool call), and then it can delete files, push to git, or hit internal services with the full authority of the user it runs as. On a box that also runs production, that is a real blast radius, not a hypothetical.
Three controls, in order of how much they buy you:
- Run it as a dedicated, unprivileged user. Not root, not a member of
sudo. Create anagentuser with its own home, install Node and the CLI there, and a misread instruction stays confined to that account. This is the single highest-value decision. - Keep production credentials off the box. No prod
~/.aws/credentials, no.envcopied down from production, no database password with write access to anything that matters. Give it a staging or read-only credential. - Use the built-in sandbox. With Docker or Podman installed,
gemini --sandbox(orGEMINI_SANDBOX=docker) runs the agent's tool calls inside a container isolated from the host filesystem and network. It is not a substitute for the unprivileged user, but it is a strong second layer when the same VPS is doing real work.
If you are running Gemini CLI next to other self-hosted tooling — an MCP server exposing tools to the agent on the same VPS, say — treat each added capability as more surface the agent can reach, and scope the tokens it is handed to exactly one job.
Quota, cost, and which auth path you chose
The auth path decides how you are billed. A personal Google account (the OAuth path) uses the free Gemini Code Assist tier, with real per-minute and per-day limits; exceed them and requests return a rate-limit error until the window resets. An API key from AI Studio can be free-tier or billed depending on the project — a billed key raises the limits and charges per token. Vertex and Cloud-project auth bill through Google Cloud.
Two practical notes. An unattended agent in a loop can burn quota fast, so watch it the first few times before you trust it to a cron job. And if your reason for a server-side model is privacy or unmetered inference rather than Google's hosted models, that is a different tool — self-hosting an open LLM with Ollama on a VPS keeps the weights and the prompts on your own box, at the cost of running a much smaller model than Gemini.
Keeping it updated
Gemini CLI ships often. Because you installed it into a user-owned prefix, updates never need sudo:
npm install -g @google/gemini-cli@latest
gemini --version
There are release channels: @latest is stable, @preview is the weekly preview, @nightly is the bleeding edge — pin to @latest on anything you rely on. On nvm, global packages live under the active Node version, so after nvm use to switch Node you may need to reinstall the CLI. Read the release notes rather than chasing every patch.
Failure modes, with the exact strings
npm WARN EBADENGINE Unsupported engine ... required: { node: '>=20' }, then the CLI crashing at runtime. Node is too old — the distro's 18.19.1, which is also past end-of-life. Install Node 20+ from NodeSource or nvm, confirm with node --version, and if you have several Nodes installed, check which node points at the new one and not /usr/bin/node.
npm error code EACCES / permission denied, mkdir '/usr/lib/node_modules/...'. A global install into a root-owned prefix. Do not sudo it — set npm config set prefix ~/.npm-global, put ~/.npm-global/bin on PATH, and reinstall as your normal user. If an earlier sudo npm left root-owned cache files (Your cache folder contains root-owned files), run sudo chown -R $(id -u):$(id -g) ~/.npm.
Failed to open browser, a login that hangs, or a redirect_uri=http://localhost:PORT you cannot reach. The OAuth flow wants a browser the server does not have, and its localhost callback points at the server, not your laptop. Use the API-key path (GEMINI_API_KEY), or pin OAUTH_CALLBACK_PORT, forward it over SSH with ssh -L, and open the URL locally.
The process vanished when SSH dropped. You ran gemini straight from the SSH shell, so it was a child of that shell and died with the pty on disconnect. Nothing to recover. Start every session with tmux new -A -s gemini and run the CLI inside it.
Auth still fails with the key set — the CLI drops back to its auth picker, or a request returns API key not valid with HTTP 400. The key is not in the environment the CLI sees. Confirm with printenv GEMINI_API_KEY; if it is empty, your ~/.gemini_env was never sourced — check the line is in ~/.bashrc, which interactive shells (tmux included) read but cron and other non-interactive shells do not. A stray space or quote inside the key value also produces API key not valid.
429 / RESOURCE_EXHAUSTED / a rate-limit message. You hit the quota for whichever tier your auth uses. Wait for the window to reset, slow the agent down, or move to a billed API key. An agent stuck in a retry loop keeps hitting this — stop it and check what it is doing.
FAQ
How do I authenticate Gemini CLI on a headless server?
Use an API key, not the browser login. Create a key in Google AI Studio, put it in a mode-600 file your shell sources (export GEMINI_API_KEY=...), and the CLI skips the OAuth browser flow entirely. If you specifically want the personal-account free tier, pin the loopback port with OAUTH_CALLBACK_PORT=8085, forward it back to your laptop with ssh -L 8085:localhost:8085 user@server, and open the printed URL locally — but that needs you present at a browser, so it is no good for scripts.
Why does the npm global install want sudo, and how do I avoid it?
Because npm's default global prefix is /usr/lib/node_modules, which your user cannot write to, so a plain npm install -g fails with EACCES. The wrong fix is sudo npm -g, which leaves root-owned files that break later installs. The right fix is to point the prefix at your home (npm config set prefix ~/.npm-global) and add its bin to PATH, or use nvm, which installs global packages under your home automatically.
How do I keep Gemini CLI running after I disconnect?
Run it inside tmux. A process started from your SSH shell dies when the connection drops because it is a child of that shell; tmux runs the shell under a detached server that survives the disconnect. Use tmux new -A -s gemini, run gemini inside, detach with Ctrl-b d, and reattach later with tmux attach -t gemini.
Is it safe to run Gemini CLI on a production box?
Only with care, because an agent with shell access can do anything the user it runs as can do. Run it as a dedicated unprivileged user with no sudo, keep production credentials off the machine, avoid --yolo auto-approval, and use --sandbox (Docker or Podman) to isolate tool calls from the host. The account it runs under matters more than any single flag you set.
Do I need to open any firewall ports for Gemini CLI?
No. It is a client that makes outbound HTTPS calls to Google's APIs, so it needs outbound port 443 but no inbound ports. If you use the OAuth tunnel, the pinned callback port (say 8085) lives on localhost and is reached through your SSH forward, not an open inbound port. Keep inbound locked down.