SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Run an SEO audit agent on your VPS

The seo CLI turns a spare VPS into an SEO audit box: a systemd timer runs the crawl, and your agent queries the results over a local MCP server.

What an SEO audit agent on a VPS actually does

An SEO audit agent is four things on one machine: a crawler that reads your site, a report engine that turns the crawl into findings, a scheduler that runs it without you, and an MCP (model context protocol) server that lets an AI agent query the findings as tool calls. A VPS is the natural home for all four because it is awake at 03:30 and your laptop is not. The agent then answers "what broke on the site since last week" from data already on the disk, instead of you opening a dashboard.

The tool in this guide is seo, a command line program published on npm. The code is under the Apache-2.0 licence, and the project describes it as roughly seventy reports behind one command. Everything runs on your server. Crawls, saved reports and cached data stay in your user config directory, and the MCP server it starts talks over standard input and output rather than a network port.

One distinction decides how much of this you install: the technical crawl reads your own public HTML over HTTP and needs no Google account at all. The Search Console and GA4 (Google Analytics 4) connection is a separate, optional read-only sign-in. If you do not want a VPS holding an OAuth (open authorization) token for your Google account, skip that half. The crawl half is still a complete tool.

Why the crawler belongs on a server that is always on

A crawl is a long, boring, scheduled job. It is the same shape as a backup. Run it from a laptop and it runs when the lid is open, which means it runs on the days you were already paying attention, and skips the weeks you were not.

A VPS also gives the crawl a fixed source address. That matters because you can allowlist that one address in your rate limiter and in your bot rules, so your own audit is never the traffic your defences fight. It also means the requests in your access log are identifiable as yours.

The logs are the other reason. Your web server's access log is already on this box, and seo server-logs analyze --file ./access.log --json reads it in place. On a laptop you would be copying gigabytes of log down a home connection first. If you are already running MCP servers on a VPS, this is one more stdio process next to the ones you have.

Install the seo CLI on Ubuntu

The package requires Node 22.19.0 or newer. Check what you have first, because distribution repositories often carry an older Node than the package needs.

node -v

If that prints anything below 22.19.0, install a current Node from the NodeSource repository, then check again.

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node -v

On an older Node the install still appears to work: npm prints npm warn EBADENGINE Unsupported engine and carries on, so the failure arrives later, inside Node, on your first real command. Fix the version before you fix anything else.

Install the package globally, but not as root. A plain npm i -g writes into /usr/lib/node_modules, which your user cannot write, so npm stops with npm error code EACCES. Setting an npm prefix inside your home directory removes the need for sudo entirely, which means a compromised postinstall script cannot touch the system.

mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
. ~/.bashrc
npm i -g seo@0.2.36
command -v seo
seo help

Pin the version. A bare npm i -g seo installs whatever is newest at that moment, so two servers built a week apart run different code and produce reports you cannot compare. Version 0.2.36 was current in August 2026; check npm for the newest before you pin, and record the number you chose. command -v seo should print a path under ~/.npm-global/bin. Keep that path, because systemd will need it later.

Run a technical crawl with no Google account

Start with the local report. It takes a URL, fetches the site, and needs nothing else.

seo report --url https://example.com
seo crawl https://example.com --format pretty --max-pages 200

When the output looks right, save a crawl so you have something to compare against later.

seo crawl https://example.com --save
seo crawl-reports
seo crawl-reports --compare latest --against previous

The comparison is the part worth automating. A single crawl tells you the site has 41 pages missing a meta description, which you already suspected. Two crawls a week apart tell you that 9 of them appeared since Tuesday, which points at whatever you deployed on Tuesday.

Useful limits: --max-pages and --max-depth bound the run, --include and --exclude take URL patterns, --no-external skips outbound link checking, and --no-sitemap ignores the sitemap. Use them. A crawl of your own site still costs your own CPU and bandwidth, because the requests land on the same VPS that serves them if the site lives there too.

The crawler fetches HTML over HTTP and does not run a browser, so it never executes your JavaScript. On a site that renders its content client side, the crawler sees the shell document the server sent and reports thin or empty pages that look fine to you. Prove which case you are in before you believe the report:

curl -s https://example.com/some-page | wc -c
curl -s https://example.com/some-page | grep -c "<h1"

If curl returns a few kilobytes of loader and no heading, that is what search crawlers using a plain fetch see as well, and that is a real finding rather than a tool bug. Auditing a client-rendered site properly needs a headless browser your agent can drive on the same server.

Should you connect Search Console and GA4?

seo start runs an interactive setup and a normal Google sign-in for read-only Search Console and Analytics access. It is optional, and it buys real things: the reports that rank what to fix next, such as seo quick-wins for pages sitting at positions 4 to 10 with a low click-through rate, and seo second-page for pages averaging positions 10 to 20, are built on query data only Google has. A crawl cannot know which page lost impressions.

The cost is a credential on a server. Tokens go to the system keychain when one exists, and fall back to a private file with mode 0600 in your config directory otherwise, which is what happens on a bare VPS with no desktop keyring. You can force either with seo auth storage --keychain or seo auth storage --file.

A headless server has a second problem: the sign-in expects a browser. If the flow prints a localhost URL, forward that port from your workstation with ssh -L 8080:localhost:8080 user@your-server and open it locally. For anything scripted, the project documents a service account instead, read from an environment variable:

SEO_GOOGLE_SERVICE_ACCOUNT_JSON='{"...":"..."}' seo report --site sc-domain:example.com --json

Before and after connecting anything, use the commands that tell you what is stored and let you take it back.

seo doctor
seo privacy
seo auth status
seo auth logout --all

If what you actually wanted was traffic numbers and not search queries, you do not need Google on this box at all. Self-hosted web analytics on the same server gives you page views from your own logs, and the crawl covers the technical side.

Schedule the crawl with a systemd timer

One unit runs the crawl, one timer decides when. Write /etc/systemd/system/seo-crawl.service, replacing youruser and the path with your own.

[Unit]
Description=Nightly SEO crawl of example.com
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=youruser
Environment=HOME=/home/youruser
ExecStart=/home/youruser/.npm-global/bin/seo crawl https://example.com --save --max-pages 500

Two lines in there are the ones people get wrong. ExecStart is an absolute path because systemd does not read your shell profile, so a bare seo is not found and the unit dies before the program starts. Environment=HOME= is set explicitly because the tool keeps its profiles, saved crawls and tokens under your user config directory, and a unit running with a different HOME finds none of them and quietly audits nothing you configured.

Then /etc/systemd/system/seo-crawl.timer:

[Unit]
Description=Run the SEO crawl every night

[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true means a run missed while the server was down happens shortly after the next boot, so a reboot does not silently create a gap in your history. RandomizedDelaySec=900 spreads the start across fifteen minutes, which keeps the crawl off the same second as every other 03:30 job on the box.

sudo systemctl daemon-reload
sudo systemctl enable --now seo-crawl.timer
systemd-analyze calendar "*-*-* 03:30:00"
systemctl list-timers seo-crawl.timer
sudo systemctl start seo-crawl.service
journalctl -u seo-crawl.service -n 50 --no-pager

systemd-analyze calendar prints the next elapse it computed, which catches a typo in the expression before you wait a day to find out. list-timers should show a NEXT column with tomorrow's date; an empty result means the timer is not enabled. Starting the service by hand runs the crawl immediately, and the journal shows exactly what the scheduled run will do.

Add --fail-on high to the ExecStart line once you trust the output. It is the project's CI threshold flag, so a high severity finding exits non-zero, the unit is recorded as failed, and systemctl --failed becomes a one-line health check for your site. Run it by hand once and confirm the exit code before you depend on it: seo crawl https://example.com --fail-on high; echo $?.

Give your agent the MCP server, and nothing else

The same reports are exposed over MCP, so an agent can call them directly.

seo mcp install
seo mcp install --claude-code
seo skill install

seo mcp install detects installed clients and writes their config for you. The server itself is stdio: the client starts seo mcp serve as a child process and speaks to it over standard input and output. There is no port, no listener, and nothing on the network to find. Confirm it:

ss -ltnp | grep seo

That returns nothing while an agent is mid-audit, which is the whole security model. Protect it by leaving it alone. The tempting next step, putting an HTTP bridge in front of the stdio server so an agent on another machine can reach it, publishes read access to your crawl history and, if you connected Google, your Search Console data. MCP over stdio carries no authentication of its own, because it never needed any. Keep the agent on the same VPS and reach the agent over SSH.

What an SEO audit agent cannot do

It audits your site. It does not rank it. No local tool moves a position in a search result; it finds the broken canonical tag and the 404 in your nav, and you fix them. Treat any output that sounds like a promise as a summary of your own site's HTML.

Numbers from third-party research providers such as DataForSEO, Semrush or Ahrefs are estimates, and the project says so in its own documentation. Volume, difficulty and visibility are models of Google, not readings from it. Search Console figures are Google's own and are sampled and delayed.

The IndexNow support submits URLs and reports whether the request was accepted. An accepted submission confirms receipt only. It does not prove a URL was crawled, indexed, ranked, or shown to anyone.

Log analysis has a matching limit that is worth stating plainly, because it is the mirror image of the job your server already does. User agent strings can be set to any value, so a line claiming to be Googlebot is a claim and not an identity. Verifying a crawler means a reverse DNS lookup or a published address range, which is exactly the ground covered when blocking AI crawlers on your server. The audit tool tells you who says they visited. Your firewall decides who gets to.

Failure modes, with the strings you will see

seo: command not found after install. The npm prefix put the binary in ~/.npm-global/bin, and that directory is not on your PATH in this shell. Re-run . ~/.bashrc, or check with npm config get prefix and command -v seo.

The unit fails instantly with status=203/EXEC. systemd could not execute ExecStart. The path is wrong, or you wrote a bare command name. Paste the output of command -v seo into the unit file.

The service runs, exits 0, and no report appears. The unit ran with a different HOME than your interactive shell, so it wrote its state somewhere else. Set Environment=HOME=/home/youruser, run sudo systemctl start seo-crawl.service, then check seo crawl-reports as the same user.

The timer never fires. systemctl list-timers seo-crawl.timer shows nothing because you enabled the service instead of the timer, or the file is missing WantedBy=timers.target. Enable the timer unit by name, including the .timer suffix.

Every page in the crawl is a 403 or a 429. Your own protection is refusing your own crawler. Watch it happen with sudo tail -f /var/log/nginx/access.log while the crawl runs, then allowlist the VPS address in the rule that is matching, whether that is a rate limiter, a bot filter or a CDN setting.

Pages look empty in the report and fine in the browser. No JavaScript is executed during the crawl. Compare curl -s <url> | wc -c against what you see rendered. If the fetched HTML really is a near-empty shell, the report is right.

FAQ

Do I need to connect Google Search Console to run an SEO audit?

No. seo report --url https://example.com and seo crawl https://example.com --save run a full technical crawl over plain HTTP with no Google connection at all, so a VPS never has to hold a token for your account. What you give up is query data: impressions, average position and click-through rate come only from Search Console, so the reports that rank your next best action, such as seo quick-wins and seo second-page, need that read-only sign-in to work.

Is the MCP server safe to expose to the internet?

There is nothing to expose, and that is the point. seo mcp serve uses stdio transport, so the agent starts it as a child process and communicates over standard input and output. Running ss -ltnp | grep seo returns no socket. If you put an HTTP bridge in front of it so a remote agent can connect, you have published unauthenticated read access to your crawl history and your Search Console data, because MCP over stdio has no authentication layer of its own. Run the agent on the same VPS and connect to that server over SSH.

Why does the crawl report thin pages that look fine in my browser?

The crawler fetches HTML over HTTP and does not run a browser, so client-rendered content is invisible to it. Check with curl -s https://example.com/page | grep -c "<h1". A count of zero on a page that clearly shows a heading means the content is assembled by JavaScript after load. The other cause is your own defences: if the crawl shows 403 or 429 for every URL, a rate limiter or bot rule is blocking the VPS address, and your web server's access log shows the refusals as they happen.

Will running this improve my rankings?

No. It reports on your site; it does not rank it. The value is that a scheduled crawl finds the broken canonical tag, the redirect chain and the page that started returning 500 on the night you deployed, and it finds them before a search engine does. Numbers that come from third-party research providers are estimates of Google's behaviour rather than measurements of it, so treat difficulty and volume figures as directional and act on the technical findings first.