
The RAM-Backed Test
This technical deep-dive explains how to leverage Linux tmpfs to run Postgres entirely in memory, eliminating the disk I/O bottleneck from your CI pipeline.
Disk I/O is a performance tax you’re paying for a product you never intend to keep. When you run your test suite, you aren't building a historical archive or a durable ledger; you are building a sandcastle that you intend to kick over the second the CI pipeline turns green. Yet, by default, your database—likely PostgreSQL—spends a massive chunk of its lifecycle meticulously ensuring that if the power goes out, every single insert is safely etched into physical blocks of silicon.
In a production environment, this persistence is a miracle of engineering. In a CI environment, it is a millstone around your neck.
If your integration tests feel sluggish, it’s rarely the CPU’s fault. It’s almost certainly the "Wait" state. The CPU is sitting idle, twiddling its thumbs, while the kernel waits for the disk controller to confirm that a row of test data has been safely written to the Write-Ahead Log (WAL). We can stop this. We can move the entire database into the realm of pure electricity by leveraging Linux tmpfs.
The Durability Paradox
Postgres is designed to be indestructible. By default, it follows the ACID (Atomicity, Consistency, Isolation, Durability) properties to the letter. The "D" is what kills test performance. Every time you run a COMMIT, Postgres issues an fsync() system call. This forces the operating system to flush the data from the kernel's page cache to the actual hardware.
On a standard SSD, this is fast. On an NVMe, it’s faster. In a virtualized CI environment (like GitHub Actions or a constrained Jenkins node), it is often agonizingly slow because you’re likely using "Network Attached Storage" with significant latency overhead.
But here is the thing: We do not care about durability in a test. If the CI runner crashes, the database is gone anyway. We don't need a WAL that survives a power failure. We need a database that lives and dies in RAM.
Enter tmpfs: The Linux Secret Weapon
On Linux, /dev/shm (Shared Memory) is a mount point for tmpfs. It looks like a folder, it acts like a folder, but it resides entirely in your system's volatile memory. Unlike an old-school RAM disk, tmpfs is dynamic; it only consumes the RAM it actually needs based on the files stored within it.
When you point Postgres to a data directory located on a tmpfs mount, the fsync() calls effectively become "no-ops" in terms of physical latency. The data is "flushed" to RAM, which happens at the speed of your memory bus—orders of magnitude faster than a disk.
Implementation: Running Postgres in RAM Locally
If you are running Postgres natively on a Linux machine (not in Docker yet), moving the data directory is straightforward. I’ve used this trick on legacy monoliths where the test suite took 20 minutes; shifting to RAM cut that time by nearly 40% immediately.
First, identify your shared memory space. Most distributions default to half of your physical RAM.
df -h /dev/shmNow, let's initialize a temporary database cluster there. Note that we use initdb to create a fresh environment specifically for our test run.
# Create a directory in the shared memory space
mkdir -p /dev/shm/pg_test_data
# Initialize the Postgres cluster in RAM
initdb -D /dev/shm/pg_test_data
# Start the server, pointing to our RAM-backed directory
# We use a custom port to avoid clashing with a system Postgres
pg_ctl -D /dev/shm/pg_test_data -o "-p 5433" startWhen the tests finish, you simply stop the process and delete the directory. It’s clean, it’s ephemeral, and it’s blazing fast.
The Modern Way: Docker and tmpfs
Most of us aren't running Postgres natively on our build nodes anymore. We’re using Docker. Fortunately, Docker has a first-class flag for exactly this purpose.
When you run a container, you can use the --tmpfs flag to mount a specific path into RAM. For the official Postgres image, the data resides at /var/lib/postgresql/data.
Here is the command that will change your CI life:
docker run -d \
--name pg-ram \
-e POSTGRES_PASSWORD=password \
--tmpfs /var/lib/postgresql/data:rw,noexec,nosuid,size=1g \
-p 5432:5432 \
postgres:15-alpineLet's break down those mount options:
- rw: Read-write access (obviously).
- noexec/nosuid: Security best practices for memory-backed mounts.
- size=1g: This caps the memory usage. Be careful here; if your test data (including indexes and logs) exceeds this, Postgres will throw a "No space left on device" error.
Docker Compose Configuration
If you’re using docker-compose.yml for your local dev or CI environment, the syntax is equally simple:
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: password
tmpfs:
- /var/lib/postgresql/data:size=1G
ports:
- "5432:5432"Going Further: The "Disposable" Config
Putting the data in RAM solves the I/O bottleneck, but Postgres still wastes CPU cycles being cautious. Even if the "disk" is actually RAM, Postgres is still performing complex logic to ensure data consistency in the event of a crash.
We can tell Postgres to stop being so careful. I call this the "Kamikaze Configuration." You should never use this in production, but in CI, it's essentially free speed.
When starting Postgres, pass these flags to the server:
1. `fsync=off`: Tells Postgres not to wait for data to be flushed to the "disk."
2. `synchronous_commit=off`: The server won't wait for the WAL to be written before reporting success to the client.
3. `full_page_writes=off`: Protects against partial page writes during a crash. Unnecessary in a transient test environment.
In a Docker environment, you can pass these directly as command-line arguments:
services:
db:
image: postgres:15-alpine
command:
- "postgres"
- "-c"
- "fsync=off"
- "-c"
- "full_page_writes=off"
- "-c"
- "synchronous_commit=off"
tmpfs:
- /var/lib/postgresql/dataThe "Migration" Bottleneck
A common objection I hear is: "My tests are slow because I have 400 migrations that run on every startup."
RAM helps here, but it doesn't solve the problem of serial execution. If your application framework (like Rails, Django, or a Node-based ORM) runs migrations sequentially, it’s still performing hundreds of CREATE TABLE and ALTER TABLE statements.
The pro move is to combine RAM-backing with a Template Database.
1. Start your RAM-backed Postgres.
2. Run your migrations once.
3. Create a template from that migrated state.
4. For every test worker/thread, create a new database *from* that template.
-- Once the schema is ready
ALTER DATABASE my_app_test RENAME TO my_template;
-- For every test suite or worker
CREATE DATABASE test_worker_1 TEMPLATE my_template;Because the entire my_template exists in /dev/shm, the CREATE DATABASE ... TEMPLATE command is essentially a memory-to-memory copy. It is nearly instantaneous.
Real World Gains: A Case Study
I recently worked on a Ruby on Rails project with about 1,200 RSpec tests. On a standard CI runner, the suite took 8 minutes and 40 seconds.
Here was the breakdown:
- Default Postgres (Dockerized): 8m 40s
- Postgres with `fsync=off`: 6m 15s
- Postgres on `tmpfs` + `fsync=off`: 4m 10s
We shaved off more than 50% of the total build time by changing a few lines of configuration. We didn't refactor a single line of application code. We didn't buy faster hardware. We just stopped lying to the database about how much we cared about its survival.
Common Pitfalls and Gotchas
While RAM-backed testing is a massive win, it isn't a silver bullet without its own quirks.
1. The OOM Killer
If you have a massive test suite that generates millions of rows of logs or temporary data, you might hit your tmpfs size limit or, worse, the system's total RAM limit. If the Linux Out-Of-Memory (OOM) killer wakes up, it will target your Postgres process first.
Solution: Monitor your memory usage during a test run. Use docker stats to see how high the water mark goes. If you're hitting limits, you might need to increase the size parameter of your tmpfs or upgrade your CI runner instance type.
2. Logging
By default, Postgres logs quite a bit. If your data directory is in RAM, your logs are also in RAM. If a test fails and you need to see why the database is complaining, remember that those logs vanish the moment the container stops.
I prefer to redirect logs to stdout/stderr so they are captured by the CI provider's log collector rather than sitting in the RAM-backed data directory.
# In your Dockerfile or command
-c "log_destination=stderr"3. The "It Works on My Machine" Syndrome
Sometimes, performance issues are caused by specific database constraints or triggers that behave differently under high load. By running in RAM with fsync=off, you are testing in an idealized environment.
On very rare occasions, I’ve seen race conditions that *only* appear when the disk is slow (because the delay changes the timing of concurrent transactions). If you can't reproduce a production bug in your RAM-backed test suite, consider running a "stress test" suite against a standard disk-backed instance.
CI Provider Specifics
GitHub Actions
GitHub Actions' services block supports tmpfs. This is the cleanest way to implement this in a GHA workflow:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
--tmpfs /var/lib/postgresql/data:rw
steps:
- uses: actions/checkout@v3
# ... run your testsGitLab CI
GitLab CI handles services a bit differently. You often have to define the entrypoint or use variables to pass flags.
test:
services:
- name: postgres:15-alpine
alias: db
command: ["postgres", "-c", "fsync=off"]
variables:
POSTGRES_DB: test_db
POSTGRES_PASSWORD: password
script:
- # Your test commandsNote: GitLab doesn't always allow direct tmpfs mounting in the services definition depending on the runner's executor configuration (Docker-in-Docker vs. Socket binding). If you can't use tmpfs, at least ensure fsync=off is set.
Final Thoughts
We spend so much time optimizing our code, our compilers, and our bundlers. We argue about the efficiency of O(n) vs O(log n) in our business logic. Yet, we often ignore the fact that our CI pipelines are effectively dragging a heavy anchor across a concrete floor.
Running Postgres in RAM is the single most impactful "low-hanging fruit" optimization you can perform on a data-heavy application. It respects the nature of testing: transient, repeatable, and fast.
Give your disks a break. They weren't meant for your disposable data anyway. Turn off the safety, move your data to /dev/shm, and watch your CI minutes—and your frustration—drop.


