A Testcontainers Alternative for the Database Half
Testcontainers solved a problem that mocks never really did. Your test suite boots a real Postgres, runs against real SQL with real constraints and real transaction semantics, and throws it away when the run ends. That is the correct shape for an integration test, and it is why the npm package alone pulls around 5.5 million downloads a week and the language SDKs together sit near 24,000 GitHub stars.
It also has one requirement that never goes away. From the official getting-started page, you need a Docker-API compatible container runtime, and the three supported options are Docker Desktop, Docker Engine on Linux, or Testcontainers Cloud. That is not a footnote. It is the load-bearing assumption of the whole design: the library's job is to talk to a container runtime, so if you do not have one, you do not have Testcontainers.
Which is fine, right up until you are on a laptop where Docker Desktop is the loudest process you run, or on a CI runner where getting Docker-in-Docker to behave costs you an afternoon every few months.
What you are actually buying into
Docker acquired AtomicJar, the company behind Testcontainers, in December 2023. The library stayed open source and the maintainers kept shipping, so this is not a rug-pull story. But it does explain the shape of the escape hatch. When local Docker is the thing hurting you, the sanctioned fix is Testcontainers Cloud, which is Docker's product. You are choosing a runtime dependency and a vendor at the same time, and the alternative to the vendor's daemon is the vendor's cloud.
The day-to-day costs are smaller and more familiar. Each container takes a couple of seconds to start and pass its wait strategy, so a suite that needs Postgres plus a cache plus a search index pays that several times over before the first assertion runs. A cold runner pulls images first, and the Postgres image is a few hundred megabytes of Linux userland wrapped around the ten megabytes of database you wanted. And because it is a library, the setup lives inside your test files. There is no command you can type to get a database right now, for a migration you are debugging or a psql session you want against fixture data. Any use outside beforeAll means writing a script.
The scope we are not competing on
I want to be straight about this, because the honest comparison is narrower than the search query suggests.
If your integration tests need Kafka, Elasticsearch, LocalStack, a Selenium browser, or your own service images wired together on a network, Testcontainers is the right tool and nothing here replaces it. Arbitrary container orchestration inside a test process is the entire point of the project, and it is very good at it. Keep it.
My claim covers one slice of it. For a large share of suites, the only thing Testcontainers is booting is a database. Postgres, maybe MySQL, maybe Redis or Valkey for the cache path. That slice does not need a container runtime, because a database is a binary and the container was only ever the delivery mechanism.
This idea is older than us. pg_tmp has been handing out throwaway Postgres instances for over a decade and is still maintained, and it still works the way it always did: a shell command, a fresh initdb, a socket, gone when you are done. Its constraints are why it stayed niche. Postgres only, and you need Postgres installed on the machine first. The Layerbase CLI is the same idea with the binary problem solved, across 21 engines.
Side by side
Here is a Testcontainers setup for a Node suite that needs Postgres, using the current module API:
import { PostgreSqlContainer } from '@testcontainers/postgresql'
import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql'
import { readFileSync } from 'node:fs'
import { Client } from 'pg'
let container: StartedPostgreSqlContainer
let client: Client
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:17').start()
client = new Client({ connectionString: container.getConnectionUri() })
await client.connect()
await client.query(readFileSync('schema.sql', 'utf8'))
})
afterAll(async () => {
await client.end()
await container.stop()
})That is clean code. It also only runs where a Docker daemon is listening, and it runs from scratch on every invocation of the suite.
The Layerbase CLI version moves the database out of the test file. Install once:
npm i -g layerbaseThen create the database and load your schema:
lbase create testdb -e postgresql --start
lbase run testdb ./schema.sql
lbase url testdbpostgresql://127.0.0.1:5432/testdbYour test file goes back to reading DATABASE_URL like it does in every other environment:
const client = new Client({ connectionString: process.env.DATABASE_URL })The database is a native Postgres process, not a container, so there is no daemon, no image layer, and no port mapping to resolve. It also persists between runs, which is the trade being made here. Testcontainers guarantees a clean slate every single time; the CLI gives you a database that is still there when the suite exits, which is faster and better for debugging, and means truncation between tests is your job. lbase delete testdb -f when you want the clean slate back.
Add the other services the same way, since they are the same shape:
lbase create testcache -e valkey --start
lbase create testsearch -e meilisearch --start
lbase listOne more thing that falls out of this: because the database is a command rather than a fixture, you can point a psql session at it, run a migration by hand, or hand the URL to a coding agent. The setup is not trapped inside beforeAll.
CI, without Docker-in-Docker
Locally, native binaries are the easy answer. CI is where the Docker requirement gets genuinely annoying, because a shared runner has its own opinions about nested containers and its own image cache that you do not control.
The version I actually run creates a database over the API with a time to live and lets the platform destroy it:
name: test
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
env:
LAYERBASE_API_KEY: ${{ secrets.LAYERBASE_API_KEY }}
steps:
- uses: actions/checkout@v4
- run: npm i -g layerbase
- id: db
run: |
URL=$(layerbase cloud create ci-$GITHUB_RUN_ID \
--engine postgresql --ttl 2h --json | jq -r .connectionString)
echo "url=$URL" >> "$GITHUB_OUTPUT"
- run: psql "${{ steps.db.outputs.url }}" -f schema.sql
- run: npm test
env:
DATABASE_URL: ${{ steps.db.outputs.url }}
- if: always()
run: layerbase cloud delete ci-$GITHUB_RUN_ID --yesNo services block, no daemon, no privileged mode, nothing nested. The runner makes an HTTPS call and gets a connection string back.
The --ttl flag is the part worth dwelling on, because it is the one thing a teardown step cannot give you. Time to live is enforced server side, capped at 72 hours, so the database is destroyed on our clock rather than in a step that only runs if your job survives long enough to reach it. Cancel the workflow, blow the timeout, lose the runner mid-suite: the database still goes away. The explicit delete at the end is just there to reclaim the slot sooner. I wrote up that whole loop, including branching a seeded parent instead of starting blank, in a database per test run.
There is a real trade here too. A cloud database is a network hop away, so a chatty test suite feels the latency in a way it does not against a local socket. If your tests do thousands of small round trips, the local CLI on a self-hosted runner beats the network every time. If your tests are ordinary integration tests, the round trip is noise next to the container start you deleted.
When Testcontainers is still the answer
Keep it if your suite boots anything that is not a database. Keep it if your team already has Docker on every machine and CI runner and none of this is a live problem, because swapping a working setup for a slightly different working setup is a bad trade. Keep it if a guaranteed-pristine instance per run is a hard requirement you do not want to enforce yourself. And keep it if production parity is the point: the container running in the test is closer to the container running in your cluster than a native binary is.
What I would not do is pay the container tax on a project where the tests only ever wanted a database. That is the case this replaces.
Getting started
Locally, one install and one command:
npm i -g layerbase
lbase create testdb -e postgresql --start
lbase url testdbFor CI, create a database on Layerbase Cloud, mint an API key in your dashboard settings, and drop the workflow above into a repo. The free tier is $0 with no card, Pro is $15 a month flat, and there are no compute-hour meters on any plan, so a test suite that runs a thousand times this month costs the same as one that runs twice. If you want the local side in more depth, running databases locally without Docker covers the CLI on its own terms.
Keep reading
- DBngin alternatives for when you need a terminalDBngin starts a local Postgres in one click and asks nothing of you, which is why it keeps showing up as the answer to running databases without Docker. The ceiling is the shape of the app: no command line, so nothing it does can be scripted, checked into a repo, or run on a CI machine.
- prisma dev, PGlite, and the one-connection ceilingprisma dev runs a local Postgres on PGlite with no Docker and no config, and it is right about the problem it picked. It also takes one connection at a time and only speaks Postgres. Here is what to run when your stack needs more than that.
- Run Valkey on Windows (No Docker, No WSL)No official Valkey Windows binary exists anywhere. We manually compiled one so you can run Valkey natively on Windows without Docker or WSL.
- Run Redis on Windows (No Docker, No WSL)Redis OSS ships no native Windows binaries. Run Redis natively on Windows without Docker or WSL using custom binaries from Layerbase Desktop or SpinDB.