# SerpBear tracks your own rankings through a scraping service you pay for

> SerpBear is a self-hosted Google rank tracker that never queries Google itself: it buys SERP lookups from services such as ScrapingRobot or SerpApi, or routes them through your own proxies. The code is straightforward to run, but the provider table undercuts the first-run steps and the monthly quotas cap what unlimited keywords really cost.

**towfiqi/serpbear** — Search Engine Position Rank Tracking App

- Repository: https://github.com/towfiqi/serpbear
- Website: https://docs.serpbear.com/
- Stars: 2,128 · Forks: 310
- Language: TypeScript
- License: MIT
- Published: 2026-10-14 · Updated: 2026-10-14 · Language: en
- Canonical page: https://hysenlabs.com/projects/towfiqi-serpbear

## Each keyword check costs one metered request

SerpBear answers one narrow question: does a domain you control still appear where you expect on Google for a keyword you care about. You add a domain, you add keywords, and a scheduled job asks a third party where your domain sits. It never queries Google from its own network, and that single decision sets the price, the privacy and the ceiling on everything else.

The dependency list shows the shape of that request. axios with axios-retry handles talking to providers, https-proxy-agent covers the proxy path, and cheerio sits there to parse a returned page into a position number. Every provider lookup is billable, and the cost table puts serper.dev at $1.00/1000 req against valueserp.com at $2.50/1000 req.

The feature list advertises unlimited keywords, and nothing in the repository caps how many you may add. What caps the workload is the provider you connect. Monthly figures in the table run from 5000/mo on scrapingrobot.com up to 100,000/mo on crazyserp.com, so a keyword list of any size still meets a metered ceiling. The README never states how often the scheduler checks one keyword, so that ceiling cannot be sized from the documentation alone.

## Your own proxies swap the bill for the blocking

Proxies are the alternative SerpBear actually ships with. Step 4 tells you to skip provider signup if you intend to use proxy IPs, and the scrapers layer accepts either shape: a paid API key or your own egress. What differs is where the request comes from. A provider absorbs Google's blocking, spreads traffic across its own infrastructure and returns parsed data, and you pay per request. With your own proxies you pay nothing per request, but you inherit the detection problem yourself, and one IP that Google stops serving from quietly starves every keyword routed through it.

That gap sits in the documentation rather than the code. The README does not document the proxy address format, the authentication fields, or any per-IP request ceiling the Settings screen expects, so you configure that path from the running app instead of from a reference. By contrast, the provider table is precise enough to plan a budget against. That asymmetry is the deciding factor for anyone weighing the two modes.

## The provider table contradicts the services the prose names

The How it Works paragraph names ScrapingAnt, ScrapingRobot, SearchApi, SerpApi and HasData as the services the app calls. Three rows in the table underneath it, scrapingant.com at From $19/mo, scrapingrobot.com at Free with 5000/mo, and spaceserp.com at $59/lifetime, carry a Scraping Performance column reading Doesn't work Anymore. Two of those three are services the paragraph still presents as working.

Step 4 sharpens the conflict. The first-run instruction is to get a free API key from ScrapingRobot, which is the only Free row in the table and also one of the three marked as no longer working. A reader who follows the steps in order ends up holding a key for a service the same page has already retired.

Five more providers appear in the table and never in the prose: serper.dev, serply.io, valueserp.com, crazyserp.com and spaceserp.com. Read both halves together and the paragraph is the older snapshot of the integration list while the table is the newer one. Plan from the table, not from the sentence above it.

## The compose port and the app URL default disagree

Two settings decide where the app believes it lives, and they default to the same port for different reasons. The compose service publishes "${PORT:-3000}:3000", so PORT moves the host side while the container side stays pinned to 3000. The image is built to serve on that port, and the healthcheck reads the app root from inside the container to confirm it:

```yaml
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:3000 || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
```

NEXT_PUBLIC_APP_URL is where the disagreement bites. It defaults to http://localhost:3000, and the NEXT_PUBLIC_ prefix means the value is compiled into the client bundle at build time. The Dockerfile build stage declares no build argument for it, so setting NEXT_PUBLIC_APP_URL in a compose file after the image exists changes the server side while the built client keeps pointing at localhost:3000. Publishing for a team on another port means rebuilding the image rather than reconfiguring the container.

## Migrations run from an entrypoint the README never shows

The runtime image is assembled to migrate before it serves traffic. The Dockerfile copies entrypoint.sh into the final stage and marks it executable, and a comment in that same file names sequelize-cli as the package the script needs for database migrations. package.json defines both directions against a production environment, so this is an ordinary Sequelize migration flow rather than schema creation on first boot.

Those two scripts are the entire upgrade path a self-hoster has:

```json
    "db:migrate": "sequelize-cli db:migrate --env production",
    "db:revert": "sequelize-cli db:migrate:undo --env production"
```

What the README does not document is what entrypoint.sh does with them, whether it runs them on every boot or only on a first start, and whether a failed migration blocks the server or is swallowed. The .sequelizerc file and the database directory are both copied into the runner, so the migrations travel with the build, but the ordering is a detail only the script itself settles.

Storage stays local and small: a SQLite database under the /app/data directory the Dockerfile creates for the nextjs user, backed by the serpbear_data volume in the compose file.

## Rankings depend on a cron process the healthcheck ignores

Tracking is split across two processes, and only one of them is the website. package.json starts the server with next start and the scheduler with node cron.js, while start:all places both under concurrently:

```json
    "cron": "node cron.js",
    "start:all": "concurrently npm:start npm:cron"
```

A Dockerfile comment explains why croner, cryptr and dotenv are reinstalled in the runtime layer: cron.js runs outside Next.js, so the standalone output that ships the web server does not carry it.

That split has a monitoring consequence. The healthcheck fetches the app root with wget every 30s, a 10s timeout and 3 retries, which reports on the web server only. Nothing in the compose file reports on the scheduler, so a container can sit healthy while the job recording positions is not running at all. restart: unless-stopped covers the container, not the gap the scheduler leaves behind.

Email alerts ride on the same job. nodemailer is a runtime dependency, an email directory is copied into the image, and step 7 wires SMTP details from the Settings panel, where the daily, weekly and monthly cadence is set.

## The example environment file ships a working secret

The repository root carries a .env.example that is filled in rather than blank:

```bash
USER=admin
PASSWORD=0123456789
SECRET=4715aed3216f7b0a38e6b534a958362654e96d10fbc04700770d572af3dce43625dd
APIKEY=5saedXklbslhnapihe2pihp3pih4fdnakhjwq5
SESSION_DURATION=24
NEXT_PUBLIC_APP_URL=http://localhost:3000
```

Copied as it stands, a deployment runs with the password, the session secret and the API key from a file anyone can read. The compose file provides no fallback for PASSWORD, SECRET or APIKEY, so an unset variable arrives empty rather than random, and the README does not tell you to regenerate the example values before the first boot. Read that file as a list of keys to fill in.

Two further variables widen what the app can reach. SEARCH_CONSOLE_CLIENT_EMAIL and SEARCH_CONSOLE_PRIVATE_KEY are declared optional in the compose file and expose visit counts and impressions per keyword. Keyword research is a separate dependency on a Google Ads test account, and monthly search volume only appears for tracked keywords once that connection exists.

## Upgrading crosses three npm scripts and two dotenv majors

Three scripts describe how the project is maintained rather than run. The test entry runs jest in watch mode with verbose output, so on its own it never exits and cannot serve as a pipeline step; test:ci is the non-interactive variant, and test:cv writes coverage into a coverage directory. A third wraps standard-version for tagging.

Beyond that, the image carries a wrinkle of its own. It builds from node:22-alpine while package.json still declares next ^12.3.4, in three stages that build a standalone output and then copy only public, the standalone directory, static assets, cron.js, email, database, .sequelizerc and entrypoint.sh into the runner. Because that traced tree is the source of the copy, the runner installs four packages by hand, and one of them does not match the declared dependency:

```dockerfile
    RUN chmod +x /app/entrypoint.sh && \
    rm -f package.json && npm init -y && \
    npm install --no-package-lock \
      croner@9.0.0 \
      cryptr@6.4.0 \
      dotenv@1
```

croner and cryptr match the carets in package.json. dotenv does not: the dependency tree asks for ^16.0.3 while the process reading the environment at boot gets dotenv@1. The image also removes package.json and regenerates one, so the container no longer carries the manifest you read in the repository. The @googleapis/searchconsole package is reinstalled for the same tracing reason, with a comment blaming module resolution that Next.js 12 file tracing does not follow.

The last push was on 2026-05-14, two months past v3.1.0 on 2026-03-27, and the tags step from v2.0.7 on 2025-02-23 through v3.0.0 on 2026-03-01. The repository is MIT licensed, with no additional terms among the top-level entries.

## Conclusion

SerpBear suits a site owner with a small keyword list who wants position history in a database they control, and it suits nobody who wants search data without paying a metered third party. It stops fitting the moment monthly lookups outgrow the cheapest provider plan, because nothing in the app prices its own workload or notices that a provider row has been retired. Before deploying, open the compose file, change PASSWORD, SECRET and APIKEY rather than copying .env.example, point a real domain at NEXT_PUBLIC_APP_URL, and settle which scraping provider you will actually pay for.

## FAQ

### How do I run SerpBear without paying for a SERP scraping service?

You can point SerpBear at your own proxy IPs from the Settings screen and skip the provider signup in step 4. The README does not document the proxy address format or the authentication the Settings screen expects, so that path has to be configured from the running app.

### Which environment variables does SerpBear need before the container starts?

The compose service passes USER_NAME, PASSWORD, SECRET, APIKEY, SESSION_DURATION and NEXT_PUBLIC_APP_URL, and only the first, the fifth and the last carry defaults. SEARCH_CONSOLE_CLIENT_EMAIL and SEARCH_CONSOLE_PRIVATE_KEY are declared optional and relate to the Search Console integration.

### Do I need a Google Ads account to use SerpBear keyword research?

The keyword research and keyword idea generation feature works by connecting a Google Ads test account, and monthly search volume for a tracked keyword only becomes visible after that integration. The feature list ties the same capability to the tracked website content.

### Where does SerpBear keep its data?

The compose file mounts the named volume serpbear_data at /app/data, and the Dockerfile creates that directory and hands it to the nextjs user with uid 1001. A Dockerfile comment names sequelize-cli as the package entrypoint.sh requires for database migrations.

### What does the SerpBear test script actually run?

The test script runs jest in watch mode with verbose output, which keeps waiting for file changes instead of exiting. test:ci is the variant meant for a pipeline, and test:cv adds coverage written into a coverage directory.

## Sources

- [License: MIT](https://github.com/towfiqi/serpbear/blob/main/LICENSE)
- [Project website](https://docs.serpbear.com/)
- [README](https://github.com/towfiqi/serpbear/blob/main/README.md)
- [Releases](https://github.com/towfiqi/serpbear/releases)
- [towfiqi/serpbear on GitHub](https://github.com/towfiqi/serpbear)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/towfiqi-serpbear
