Self-host Open Connector for AI agents
Run the Open Connector auth gateway on your own VPS so your agents never hold a SaaS token: pinned image, TLS origin, OAuth callbacks, backups.
What Open Connector does for an AI agent
Self-hosting Open Connector puts one auth gateway between your AI agents and every software as a service (SaaS) API they call, so the agent never holds a provider token. It is an open source gateway from OOMOL Lab, licensed Apache 2.0. It runs as one container, keeps its state in a single SQLite file, and exposes provider actions over HTTP and over MCP (model context protocol).
The pain starts at the second integration. Every provider has its own OAuth (open authorization) flow, its own refresh token lifetime, and its own scope names. Wiring five providers into an agent by hand means five redirect handlers, five credential stores, and five refresh loops that have to run before a token expires. Almost nobody writes that code. They mint one long-lived personal access token per service and paste it into the agent config, an environment file, or the prompt itself. That token is then readable by every tool the agent runs, and it lands in the transcript, which is the failure keeping secrets out of AI agents describes.
An auth gateway splits the credential in two. The gateway stores the provider credential and runs the OAuth flow. The agent gets a runtime token that is only valid against the gateway. When the agent calls an action, the gateway loads the stored credential, injects it into the outbound request server side, and returns only the response body. The agent never receives the provider access token, so a leaked agent transcript costs you one revocable runtime token instead of your GitHub account.
The catalog advertises more than 1,000 providers and 10,000 prebuilt actions, which is the project's own figure and not something you can verify from the outside. What you can verify is the shape: one HTTP endpoint per action, one stored connection per provider, one token per agent.
Why self-host Open Connector instead of using a hosted connector service
A hosted connector service does the same work, and it holds the refresh tokens for every provider you connect to it. A refresh token for Google or GitHub is a long-lived key to your mail and your repositories, and it usually survives a password change. Their breach becomes your breach. Self-hosting moves those records into SQLite on a machine you rent and administer, sealed with a key that never leaves your box.
Say the cost out loud before you start. This VPS becomes the most valuable server you run. It holds working credentials for a dozen services in one file, so it earns the treatment you give a password manager host: a firewall that exposes only 443, no shared logins, a backup you have actually restored once, and an alert when it stops answering. If you would not put your password vault on this box, do not put the connector on it either.
Pin a version before you install anything
Open Connector is young. The repository first appeared on 29 June 2026, and as of 1 August 2026 the newest tagged release is v1.3.3, published on 30 July 2026 and also carrying the latest tag. The registry publishes a tip tag too, built from the newest commit on main.
On a project this new the moving tags move often. A docker compose pull that jumps two releases can change an endpoint your agent depends on, and you will spend the evening debugging it as an agent problem. Pin the image to a release tag, and upgrade when you decide to, after reading the release notes.
Deploy Open Connector behind TLS on your own VPS
Before the container starts you need:
- Docker with the Compose plugin, on Ubuntu 24.04 or something close to it
- a hostname whose A record points at this VPS, for example
connect.example.com - a reverse proxy that already terminates TLS (transport layer security) for that hostname
- two random secrets, generated below
The Traefik reverse proxy for multiple Docker Compose apps covers the proxy side. The same certificate plumbing, start to finish for a single app, is in the n8n on a VPS with Docker and HTTPS guide.
Generate the secrets first. The encryption key seals the stored credentials. The admin token protects the web console and the whole /api surface. Neither has a default, and the runtime starts happily without them.
mkdir -p ~/open-connector && cd ~/open-connector
umask 077
printf 'OOMOL_CONNECT_ENCRYPTION_KEY=%s\n' "$(openssl rand -base64 32)" > .env
printf 'OOMOL_CONNECT_ADMIN_TOKEN=%s\n' "$(openssl rand -base64 32)" >> .env
chmod 600 .envCopy both values into your password manager now, before the first start. The encryption key has no recovery path, and the reason is in the failure list further down.
Now compose.yaml. It differs from the upstream example in two places, and both matter.
services:
connector:
image: ghcr.io/oomol-lab/open-connector:v1.3.3
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
volumes:
- connector-data:/app/data
environment:
OOMOL_CONNECT_DATA_DIR: /app/data
OOMOL_CONNECT_ORIGIN: "https://connect.example.com"
OOMOL_CONNECT_ENCRYPTION_KEY: "${OOMOL_CONNECT_ENCRYPTION_KEY:?set this in .env}"
OOMOL_CONNECT_ADMIN_TOKEN: "${OOMOL_CONNECT_ADMIN_TOKEN:?set this in .env}"
volumes:
connector-data:The first change is the pinned tag instead of latest. The second is the port. The upstream file publishes 3000:3000, which binds every interface on the host. Docker writes its published ports into the NAT (network address translation) table before the ufw filter chain ever sees the packet, so ufw deny 3000 does not close that port, the trap described in why Docker ports bypass ufw. Writing 127.0.0.1:3000:3000 publishes on the loopback interface only, and your reverse proxy connects from the same host.
The :? marks each variable as required, so the stack refuses to start when .env is missing instead of starting with credentials unencrypted. Keeping the values in .env rather than in the compose file is the pattern from Docker Compose env files and secrets.
docker compose up -d
docker compose logs -n 30 connector
curl -s http://127.0.0.1:3000/health
sudo ss -tlnp | grep 3000/health answers { "ok": true } once the runtime is up. ss must print 127.0.0.1:3000. A line reading 0.0.0.0:3000 means the port mapping is still the upstream one, and the gateway is answering the whole internet directly. Connection refused on the health check means the container is not listening yet, so read the logs before you touch the proxy.
Traefik labels for the same service
labels:
- "traefik.enable=true"
- "traefik.http.routers.connector.rule=Host(`connect.example.com`)"
- "traefik.http.routers.connector.entrypoints=websecure"
- "traefik.http.routers.connector.tls.certresolver=le"
- "traefik.http.services.connector.loadbalancer.server.port=3000"When Traefik runs in Docker on the same host, attach this service to the Traefik network and delete the ports: block, because Traefik reaches the container on the internal network and nothing needs publishing to the host at all. certresolver=le has to match the resolver name in your Traefik static config, or the router comes up with no certificate.
Why OAuth forces you to have a real hostname
OOMOL_CONNECT_ORIGIN is the setting people skip, and skipping it breaks OAuth in a way that reads like a provider bug. The runtime builds its redirect URI from that origin, in the form <origin>/oauth/callback. Left unset, the origin defaults to http://localhost:3000, so the runtime sends the provider a redirect URI of http://localhost:3000/oauth/callback while your OAuth app has https://connect.example.com/oauth/callback registered. The two strings differ, so GitHub answers:
The redirect_uri MUST match the registered callback URL for this application.An OAuth provider redirects a browser back to that URI, which means it has to be an address the outside world can reach, and providers reject plain http:// for anything except localhost. That is the entire reason this deployment needs a hostname and a certificate. Set the origin before the first start, because the value is read at startup: after editing .env or compose.yaml, run docker compose up -d again to apply it.
Connect your first provider over OAuth
Create the OAuth app at the provider first. On GitHub the path is Settings, then Developer settings, then OAuth Apps, then New OAuth App. Set the authorization callback URL to https://connect.example.com/oauth/callback. Keep the client ID and the client secret.
Every /api call carries the admin token, so export it once for the shell session.
export ADMIN_TOKEN='paste-the-admin-token'
curl -s https://connect.example.com/api/oauth/configs \
-H "authorization: Bearer $ADMIN_TOKEN"That listing shows the redirect URI the runtime expects for each provider, which makes it the fastest check that your origin took effect. If it still says localhost, the container is running with the old value and the OAuth flow will fail at the last step.
Store the client credentials, then start an authorization.
curl -s -X PUT https://connect.example.com/api/oauth/configs/github \
-H "authorization: Bearer $ADMIN_TOKEN" \
-H 'content-type: application/json' \
-d '{"clientId":"...","clientSecret":"..."}'
curl -s -X POST https://connect.example.com/api/oauth/authorizations \
-H "authorization: Bearer $ADMIN_TOKEN" \
-H 'content-type: application/json' \
-d '{"service":"github"}'The second call returns an authorizationUrl. Open it in a browser, approve the scopes, and the provider sends the browser back to /oauth/callback, where the runtime exchanges the code and stores the credential. The web console at your origin walks through the same steps with a form, behind the same admin token. Providers that use a plain API key skip all of this: PUT /api/connections/<service> with {"authType":"api_key","values":{"apiKey":"..."}} stores the key directly.
Give each agent a runtime token, never the credential
The agent authenticates to the gateway with a runtime token, which the admin API mints.
curl -s -X POST https://connect.example.com/api/runtime-tokens \
-H "authorization: Bearer $ADMIN_TOKEN" \
-H 'content-type: application/json' \
-d '{"name":"research-agent"}'The response carries a token beginning oct_. Issue one per agent and name it after that agent, because revoking a token you cannot identify means revoking all of them. The agent then calls actions over ordinary HTTP.
curl -s -X POST https://connect.example.com/v1/actions/github.get_current_user \
-H "authorization: Bearer oct_..." \
-H 'content-type: application/json' \
-d '{"input":{}}'A healthy answer is an envelope whose success field is true, with the provider payload under data. The GitHub token is not in that response anywhere. For an MCP client, point it at https://connect.example.com/mcp with the same bearer header, and the gateway offers discovery tools such as search_actions and execute_action rather than one tool per API, which keeps the agent's tool list small. Running MCP servers on a VPS covers the client half of that wiring.
Run one more check before you call this finished. Repeat the action call with the authorization header deleted. The project's own quickstart calls /v1 with no bearer at all, so an install with no runtime auth configured will execute actions for anybody who can reach the port. If your unauthenticated call succeeds, you have two ways out: configure runtime tokens and confirm the anonymous call now fails, or restrict /api, /v1 and /mcp at the reverse proxy to the addresses your agents come from. Only /oauth/callback has to stay open to the world, because that is the single path a provider's browser redirect needs.
Cut the action list down to what the agent needs
A gateway with a thousand providers behind it is a wide surface to hand a language model. Two controls narrow it.
OOMOL_CONNECT_ALLOWED_ACTIONS takes a comma-separated allowlist and understands service.* and *. OOMOL_CONNECT_BLOCKED_ACTIONS is the denylist, and the denylist wins. Setting the allowlist to github.get_current_user,github.list_issues means every other action is refused no matter what the agent asks for, which is the difference between a mistake and an incident. Runtime tokens carry their own action rules on top of the global ones, and their allowedProxies list starts empty, so POST /v1/proxy/:service is refused until you grant it. That proxy endpoint forwards a raw request to a provider with your credential attached, so leave it empty unless one specific agent needs it.
OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK defaults to false, which stops a self-hosted provider connection from pointing at a private address such as the cloud metadata service on 169.254.169.254, or your database on the same network. Leave it off. Turn it on only for a provider you host yourself.
Back up the box that holds every token
Two things matter, and each is useless without the other. The database at /app/data/connect.sqlite inside the connector-data volume holds the sealed credentials. The encryption key in .env unseals them. A volume backup without the key restores nothing, and the key without the volume restores nothing, so the key belongs in your password manager and the volume belongs in your normal backup rotation.
Stop the container while you copy the SQLite file, because a copy taken during a write can restore as a corrupt database.
docker volume ls | grep connector-data
docker compose stop connector
docker run --rm -v open-connector_connector-data:/data -v "$PWD":/backup alpine \
tar czf /backup/connector-data.tgz -C /data .
docker compose start connectorThe volume name is your project directory plus _connector-data, which is why the first command is there: paste the real name into the third. Send the archive off the VPS with restic backups from a VPS, which encrypts it before it leaves, because that archive is the credential store.
The runtime keeps recent action runs as audit records, 5,000 of them by default, so the console can tell you which agent ran what and when. That log is the first thing to read when an agent behaves strangely. Point an Uptime Kuma status page at https://connect.example.com/health as well. When the gateway stops answering, agents fail in confusing ways, and knowing the gateway is down saves an hour of reading agent output.
What breaks, and the message you will see
redirect_uri_mismatch at the provider. The origin and the registered callback URL differ. Compare the exact string from /api/oauth/configs against the provider's app settings, including https against http and any trailing slash.
Every /api call returns 401. The admin token header is missing or misspelled. The header is Authorization: Bearer <token>, and the web console asks for the same token.
The container runs, and the credentials sit in plain text. This is what happens when OOMOL_CONNECT_ENCRYPTION_KEY never reaches the container, because the runtime stores credential records unencrypted instead of refusing to start. Prove it on your own install: connect a provider with an API key you can recognise, then search the database for it.
docker compose cp connector:/app/data/connect.sqlite /tmp/connect.sqlite
grep -c 'github_pat_' /tmp/connect.sqlite
shred -u /tmp/connect.sqliteA count above 0 means the key is not in effect, so check that .env sits in the same directory as compose.yaml and that docker compose config shows the value. With the key set, the same search returns 0, because the record is sealed with AES-256-GCM (advanced encryption standard, 256-bit key, Galois/counter mode).
Nothing decrypts after a restore. The encryption key changed or was lost. It is never written next to the data, by design, so there is no recovery path and no support ticket that helps. Reconnect every provider. Rotation is supported through a separate key variable and a data command in the runtime, so read the current release notes before you rotate anything.
The agent gets an error naming an action it can see in the catalog. Discovery and execution are separate. An action can appear in search_actions and still be refused by OOMOL_CONNECT_ALLOWED_ACTIONS, by the denylist, or by that runtime token's own rules.
Upgrades. Back up the volume, edit the image tag to the new release, then docker compose pull && docker compose up -d. Watch docker compose logs -n 50 connector for a migration line, and re-run the health check and one real action before you trust it again. Rolling back means putting the old tag back, which works only because you pinned it.
FAQ
Do I need a public domain to self-host Open Connector?
For providers that use an API key, no: a gateway on 127.0.0.1 is enough. For OAuth, yes in practice. The provider redirects a browser to your callback URL, so that URL has to resolve from the public internet, and providers refuse plain http:// outside localhost. Set OOMOL_CONNECT_ORIGIN to your https:// hostname before the first start, and register <origin>/oauth/callback in the provider's OAuth app.
What happens if I lose the Open Connector encryption key?
The stored credentials cannot be decrypted, and there is no recovery. The key is deliberately never stored alongside the data, so nobody holding the database can read it, including you. Your only option is to set a new key and reconnect every provider. Keep the key in a password manager and the database in your backup rotation, because a restore needs both.
Can my AI agent see the provider access token?
Not when it calls through the gateway. The agent authenticates with a runtime token starting oct_, and the gateway injects the provider credential into the outbound request on the server, returning only the response. Two things break that property: the /v1/proxy/:service endpoint, which forwards raw requests with your credential attached and whose grants start empty for a reason, and pasting an API key into the agent yourself, which skips the gateway entirely.
Should the gateway be reachable from the public internet?
Only /oauth/callback has to be. Publish the container port on 127.0.0.1 so Docker's NAT rules cannot expose it past your firewall, and put the reverse proxy in front. Then test one action call with no authorization header. If it succeeds, restrict /api, /v1 and /mcp at the proxy to the addresses your agents use until authenticated calls are the only ones that work.
Is Open Connector ready for production use?
It is Apache 2.0 licensed and moving fast: the repository appeared on 29 June 2026 and v1.3.3 shipped on 30 July 2026, so treat every version number in this guide as a snapshot of 1 August 2026. Run it pinned to a release tag, never on latest or tip, read the release notes before each upgrade, and keep a volume backup you have restored once. The design is sound for a box you own, and the risk is the version churn, not the architecture.