Skip to content

Optional · self-hosted

A Portal you run. Nobody else's cloud.

Four of the eight CodeSeal setups work with nothing but the CLI. The other four add a Portal — a service you host, which holds the keys, hands out single-use credentials, and can run the build itself so your CI provider never sees plaintext.

  • Yours to host
  • Sixty-second credentials
  • Audited
terminal
$ seal up
✓ postgres      healthy
✓ redis         healthy
✓ portal        http://localhost:3000
✓ workers       rekey · build · rotation · cleanup
 
$ seal deploy railway acme
✓ pushed        portal:1.0.0
✓ migrated      14 models
✓ live          https://codeseal.acme.dev

Half the setups need it. Half do not.

The Portal is not a component of CodeSeal — it is a component of four of the eight ways to configure it. If your CI mode is secrets or portal-dispatch, everything on this page is optional reading.

EncryptionCI modesecretsportal-dispatchportal-keyportal-build
passphraseOne shared secret.No Portal. GitHub holds the passphrase.No Portal. Your machine holds it and sends it per build.Portal. It hands the passphrase to the runner for one build.Portal. It decrypts and builds; GitHub sees nothing.
recipientPer-developer keypairs.No Portal. A runner key sits in GitHub Secrets.No Portal. Your machine holds the keys.Portal. It lends the runner key, wrapped, for one build.Portal. It decrypts with its own recipient keys and builds.

Read the column, not the row: the encryption mode never decides whether you need a Portal. If RD suits you — and for most teams it does — you can close this page.

The four things a Portal is for

  • Lending keys instead of giving them

    In portal-key the runner never holds a durable secret. It trades a bootstrap secret for a single-use token, redeems that for a wrapped key, and unwraps locally — so the Portal stores a key it cannot itself read.

  • Building where GitHub cannot look

    In portal-build a worker clones at the commit, decrypts in an isolated workspace, runs your build.sh, uploads the artefact, and scrubs. GitHub sends a SHA and receives a status.

  • Adding and removing people, centrally

    Recipients, roles and rekeys through a UI, with the admin key unlocked in the browser and held in Redis under a TTL. Removing someone rewraps every file key without anyone rotating a shared password.

  • Recording what happened

    Every unlock, token consumption, rekey and build is an audit row, with secret-shaped strings scrubbed before they are written. Exportable as CSV or JSON, scoped by role.

Five zones, and a contract at every crossing

Everything inside a box may talk to everything else inside it. Crossing a boundary costs a cookie, a bearer token, or a single-use internal token — never an assumption.

  1. The browser gets a cookie, and nothing else

    httpOnly, Secure, SameSite=Lax. No key material, no admin key, no token value ever reaches client JavaScript — every sensitive operation is a server action.

  2. The runner gets sixty seconds

    A bootstrap secret buys a single-use token whose hash lives in Redis with a one-minute expiry. Redemption compares and deletes atomically, so two requests carrying the same token cannot both win.

  3. The build container gets less

    An internal token, single-use, valid for a minute, on a namespace never exposed to the public network. It buys exactly one key handoff.

  4. Postgres holds what must survive

    Fourteen models — repositories, recipients, builds, audit entries. Every secret in it is hashed or wrapped; a full database dump yields ciphertext and Argon2id digests.

  5. Redis holds what must not

    Unlocked admin keys, single-use tokens, rate-limit counters, per-repo build locks. All TTL'd. A restart loses exactly the things that should not outlive it.

flowchart LR
    Browser["Browser<br/>developer / admin"]
    Portal["Portal<br/>route handlers"]
    Postgres[("PostgreSQL<br/>durable")]
    Redis[("Redis<br/>ephemeral, TTL")]
    Worker["Workers<br/>rekey · build · rotation"]
    GitHub["GitHub<br/>Actions runner"]
    Build["Build container<br/>CI Mode 3"]

    Browser -- "session cookie<br/>httpOnly Secure Lax" --> Portal
    GitHub -- "bootstrap secret<br/>→ single-use token" --> Portal
    Build -- "internal token<br/>60s, single use" --> Portal
    Portal --> Postgres
    Portal --> Redis
    Worker --> Postgres
    Worker --> Redis

The admin key is the sharpest edge: AES-256-GCM at rest, Argon2id to unlock, and the unlocked form lives only in Redis under a TTL — never on disk.

Running one

Three ways to have a Portal

All three read the same $CODESEAL/.env, which install.sh generated with random secrets at install time. Open it before you start anything.

Local

On your machine

Postgres, Redis, the Portal and the workers, from the compose file the installer put in $CODESEAL. Right for trying it, and for a Portal only you use.

terminal

seal up          # start
seal logs -f     # follow
seal down        # stop

Managed

On a platform

Railway, Fly, Render, Vercel, Cloud Run, App Runner or DigitalOcean. The deployer pushes the image, runs migrations and wires the managed Postgres and Redis.

terminal

seal deploy railway acme
seal deploy fly
seal deploy cloudrun

Your own

On infrastructure you have

A Terraform scaffold, or the standalone compose file against Postgres and Redis you already run. Nothing about the Portal assumes a particular host.

terminal

seal deploy aws     # terraform scaffold
# or, directly:
docker compose -f \
  $CODESEAL/compose.standalone.yml up -d

A Portal in portal-dispatch mode needs no public address at all. It calls GitHub; GitHub never calls it. That is the one arrangement where a laptop behind a home router is a legitimate production Portal — see RD.

Start without one

Four of the eight setups need no Portal, and the recommended pair is one of them. Seal a repository first; add a Portal when you want central audit, lent keys, or builds your CI provider cannot observe.