Skip to content
Hoody.com

/hoody/databases is the conventional home for SQLite databases in a Hoody container. It is an ordinary directory in the container’s own filesystem, present from the moment the container is created, and it is where hoody-sqlite resolves a bare database name such as app to /hoody/databases/app.db.

What it is not is a special filesystem. Putting a database there does not add cross-container coordination, and it does not make a file visible to another container. Safety for the writers inside one container comes from SQLite’s own locking, plus the WAL settings hoody-sqlite opens every database with. This page covers what those settings give you and where they stop.


Database access:

Direct access:


What SQLite’s concurrency model actually allows

Section titled “What SQLite’s concurrency model actually allows”

SQLite permits one writer at a time per database file. In the default rollback journal mode a writer also blocks readers. That is the source of the error most people meet first:

# AI-generated code (common pattern)
import sqlite3
# Multiple tasks running simultaneously
def process_task(task_id):
conn = sqlite3.connect('/app/data.db') # Same database
cursor = conn.cursor()
cursor.execute("INSERT INTO tasks VALUES (?, 'completed')", (task_id,))
conn.commit()
conn.close()
# Task 1, 2, 3... all running in parallel
# Result: "database is locked" when one writer does not wait for another

Two things fix that, and neither of them is a filesystem. The first is WAL mode, which lets readers keep reading while a writer writes. The second is a busy timeout, which makes a second writer wait for the first instead of failing immediately. hoody-sqlite sets both on every database it opens.

On a local filesystem this is safe: the operating system’s file locks are what SQLite relies on, and several processes in the same container, the sqlite3 CLI included, can share a database without corrupting it. They serialize rather than run in parallel, and a writer that waits longer than the busy timeout still returns a lock error.


Every container has it from the start:

Terminal window
ls /hoody/databases/
# Directory exists and is ready to use

Nothing is mounted and there is no setup step. The directory is part of the container image, and hoody-sqlite creates any missing parent directory when it creates a database under it, through POST /api/v1/sqlite/db/create or a request carrying create_db_if_missing=true. The two rollback endpoints are the exception: they refuse a missing parent directory rather than creating one.

Its role is naming rather than storage behavior. hoody-sqlite accepts three spellings for the db parameter:

app -> /hoody/databases/app.db
./app -> /hoody/databases/app.db
/hoody/databases/app.db -> itself

The service also has a confinement mode that refuses absolute paths outside /hoody/databases. A stock container starts it with --allow-any-absolute-db-path, so a database elsewhere in the filesystem works too.

Because the directory lives in the container’s own filesystem, it is per container. Another container cannot open the file, and a snapshot or copy of the container carries the databases with it like any other files.


The pools the service opens your database files with all use the same settings:

journal_mode WAL readers do not block the writer
synchronous NORMAL fsync at checkpoints rather than every commit
busy_timeout 30000 ms a blocked writer waits instead of failing at once
cache_size 64 MB
mmap_size 256 MB
temp_store MEMORY
wal_autocheckpoint 1000 pages

Write transactions begin as BEGIN IMMEDIATE, so a writer takes the write lock up front rather than discovering a conflict at commit and having to roll back. Requests the service classifies as read-only stay deferred; the classification is deliberately cautious, so some read-only SQL takes the writer path. Each database pools up to 16 connections and keeps up to 8 of them idle.

A database created through POST /api/v1/sqlite/db/create is opened with WAL and the busy timeout while it is being created, and picks up the rest of these settings when the service next opens it for a request.

The SQLite busy timeout and the service’s default request deadline are both 30 seconds. They are independent: a request that waits for another request’s write to the same database, or for a maintenance operation on it, stops waiting when its deadline passes and gets 503 REQUEST_TIMEOUT. A request can raise its deadline with ?timeout=, up to 300 seconds; that does not change the SQLite busy timeout. When the deadline passes, the request normally fails with 503, though a SQL or KV write that already committed keeps its success response.


Any SQLite library opens these files directly:

import sqlite3
conn = sqlite3.connect('/hoody/databases/database.db')
conn.execute('PRAGMA journal_mode = WAL')
conn.execute('PRAGMA busy_timeout = 30000')
cursor = conn.cursor()
cursor.execute("INSERT INTO users VALUES (?, ?)", (1, 'Alice'))
conn.commit()

Set the busy timeout yourself on every connection: it is per connection, and nothing the service does to its own connections carries over to yours. WAL is stored in the database file, so a database hoody-sqlite has opened is already in WAL — setting it again is harmless.

This works with any language that has SQLite bindings:

  • Python (sqlite3 module)
  • Node.js (better-sqlite3, sqlite3)
  • Go (mattn/go-sqlite3)
  • PHP (PDO SQLite)

One database is reachable over HTTP and through native sqlite3 at the same time:

Terminal window
# Container's own shell: native sqlite3 (fast, direct access)
sqlite3 /hoody/databases/app.db "SELECT * FROM users"
# Anywhere else: hoody-sqlite HTTP API
curl "https://{project}-{container}-sqlite-1.{server}.containers.hoody.com/api/v1/sqlite/db?db=/hoody/databases/app.db" \
-H "Content-Type: application/json" \
-d '{"transaction": [{"query": "SELECT * FROM users"}]}'
# Same database, two access paths

Each path suits different work:

  • Native access for performance-critical operations inside the container
  • HTTP access for other containers, remote monitoring, web dashboards, API integrations
  • Both are ordinary SQLite clients, so they queue against the same write lock

See: Hoody SQLite for complete HTTP API documentation.


Safe sharing inside one container:

  • Several processes can open the same database
  • WAL keeps readers running while a writer writes
  • hoody-sqlite gives its own connections a 30-second busy timeout, so a writer it blocks waits rather than failing at once; native connections set their own, and some conflicts fail immediately whatever it is

No configuration:

  • The directory is available in every container
  • Nothing to set up or mount
  • Works with the standard sqlite3 library
  • Bare names resolve under it automatically

Reachable over HTTP:

  • The same file is queryable from outside the container through hoody-sqlite
  • KV and SQL surfaces sit on one database

One container, one filesystem:

  • /hoody/databases is the container’s own directory, not a shared volume
  • Containers cannot open each other’s database files
  • Cross-container access goes through the hoody-sqlite HTTP API

One writer at a time:

  • Writes serialize; they do not run in parallel
  • A writer that waits past the busy timeout still gets “database is locked”
  • Long transactions make that likelier, whatever the path

Not a replication or backup system:

  • Data is not replicated to another host
  • Nothing is backed up by default, though scheduled backups can be configured
  • There is no failover

A web process and its background workers in the same container, sharing one database:

/hoody/databases/app.db
# Worker processes and the HTTP handler all open:
#
# WAL lets reads continue during writes; the busy timeout absorbs
# short overlaps between writers.

The usual alternative is a PostgreSQL or MySQL server that every process reaches over the network, which adds a service to operate. Here it is one file, opened directly.

Give the owning container’s sqlite service a URL and query it:

Terminal window
# From any other container, or from your laptop
curl "https://$PROJECT-$CONTAINER-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/db?db=/hoody/databases/app.db" \
-H "Content-Type: application/json" \
-d '{"transaction": [{"query": "SELECT COUNT(*) FROM events"}]}'

The file stays on the owning container’s local filesystem, so this stays inside SQLite’s safe territory. Copying the file onto a shared mount and opening it from both sides does not.

Ingestion and queries in the same container, against the same database:

Terminal window
# Ingest metrics
while true; do
sqlite3 /hoody/databases/metrics.db \
"INSERT INTO events VALUES (datetime('now'), '$data')"
done
# Query metrics at the same time
sqlite3 /hoody/databases/metrics.db \
"SELECT COUNT(*) FROM events WHERE timestamp > datetime('now', '-1 hour')"

Under WAL the read does not wait for the insert. Set PRAGMA busy_timeout on both connections so a write that collides with a checkpoint waits rather than failing.


The same databases are reachable over HTTP through hoody-sqlite.

hoody CLI prerequisite. The CLI tabs below invoke hoody db … and hoody kv …. If you haven’t already, install the CLI (npm i -g hoody-sdk) and authenticate (hoody login) so the CLI picks up your Hoody token and default base URL.

Terminal window
# Execute SQL transaction via CLI
hoody db transactions run -c $CONTAINER --db /hoody/databases/app.db \
--transaction '[{"query": "SELECT * FROM logs ORDER BY timestamp DESC LIMIT 100"}]'
# Create a new database
hoody db create -c $CONTAINER --path /hoody/databases/analytics.db
Terminal window
# Set a key-value pair
hoody kv set user:1 -c $CONTAINER --db /hoody/databases/app.db \
--body '{"name": "Alice", "email": "alice@example.com"}'
# Get a value by key
hoody kv get user:1 -c $CONTAINER --db /hoody/databases/app.db
// Inside the container: native sqlite3 for bulk inserts (faster)
const db = require('better-sqlite3')('/hoody/databases/app.db');
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 30000');
db.prepare('INSERT INTO logs VALUES (?, ?)').run(timestamp, message);
// From another container: HTTP API for remote queries. No Authorization header
// here because this container has no proxy permissions configured (the default).
// Add the credential its policy requires once you configure one.
const response = await fetch(
'https://{project}-{container}-sqlite-1.{server}.containers.hoody.com/api/v1/sqlite/db?db=/hoody/databases/app.db',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transaction: [{
query: 'SELECT * FROM logs ORDER BY timestamp DESC LIMIT 100'
}]
})
}
);

Writes stay on the fast native path while reads remain available over HTTP.

See: Hoody SQLite KV Store and SQL Operations


It is where the tooling looks, where bare names resolve, and where a confined hoody-sqlite is allowed to read:

Terminal window
# Conventional
/hoody/databases/production.db
/hoody/databases/cache.db
/hoody/databases/analytics.db

Never put a SQLite file on a path more than one container mounts, whatever the directory name:

Terminal window
# Risk of corruption: a share two containers both mount
/hoody/mounts/permanent/team-share/production.db

Set WAL and a busy timeout on your own connections

Section titled “Set WAL and a busy timeout on your own connections”

A library you open yourself sets its own busy timeout; WAL it inherits from the file, because journal_mode is stored there:

import sqlite3
conn = sqlite3.connect('/hoody/databases/app.db')
conn.execute('PRAGMA journal_mode = WAL') # persists in the file
conn.execute('PRAGMA busy_timeout = 30000') # per connection

journal_mode is stored in the database file, so it survives. busy_timeout is per connection and has to be set every time.

One writer holds the lock for the whole transaction, so the length of your longest write decides how long everyone else waits:

-- Wrong: holding the write lock through slow work
BEGIN IMMEDIATE;
-- seconds of computation
COMMIT;
-- Better: compute first, then write
BEGIN IMMEDIATE; INSERT ...; COMMIT;

WAL is not a backup:

Terminal window
# Via snapshot (captures entire container state)
POST /api/v1/containers/{id}/snapshots
{"alias": "before-migration"}
# Or use sqlite3's backup command, which is safe on a live database
sqlite3 /hoody/databases/production.db ".backup /hoody/storage/backups/backup.db"

Prefer .backup over cp on a database that is in use: a plain copy can catch the file mid-transaction. Over HTTP, POST /api/v1/sqlite/maintenance?db=/hoody/databases/production.db with {"op": "vacuum_into", "dest_path": "backup.db"} writes the same kind of consistent copy, /hoody/databases/backup.db, through hoody-sqlite itself. The destination must not exist yet.

Restoring needs the same care. Do not move or copy a backup over a database that is in use: the old -wal file stays behind, and SQLite can pair the restored file with it and corrupt the result. For a database hoody-sqlite serves, POST /api/v1/sqlite/maintenance?db=/hoody/databases/production.db with {"op": "restore", "src_path": "backup.db"} copies a standalone backup in through hoody-sqlite itself, as one write transaction, and the backup is only read. Otherwise, stop every reader and writer, hoody-sqlite included, and let their connections close: a checkpoint alone does not close the pooled connections it holds, and even reads can queue background writes. Then, for a standalone backup of the kind .backup and vacuum_into produce, put the file in place and make sure no old -wal, -shm or journal file is left beside it. A filesystem snapshot is different: restore the database and its sidecars together, as the set they were captured as.

Terminal window
# Check database sizes
du -sh /hoody/databases/*
# Vacuum to compact
sqlite3 /hoody/databases/app.db "VACUUM"
# Turn on auto-vacuum. On a database that was created without it, the
# pragma only takes effect after the VACUUM that follows it.
sqlite3 /hoody/databases/app.db "PRAGMA auto_vacuum = FULL; VACUUM;"
-- Create indexes for frequently queried columns
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_posts_author ON posts(author_id, created_at);
-- Analyze for query planner
ANALYZE;

Do I need to do anything to enable /hoody/databases?

Section titled “Do I need to do anything to enable /hoody/databases?”

No. The directory ships in every container. Start using it:

Terminal window
sqlite3 /hoody/databases/myapp.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"

Can two containers share one database file?

Section titled “Can two containers share one database file?”

No. /hoody/databases belongs to the container it is in, so another container cannot open the file at all. Putting the file on a storage share both containers mount is worse than unsupported: network filesystems do not reliably honor the locks SQLite depends on, and that is how a SQLite database gets corrupted rather than merely locked.

When another container needs the data, query the owning container’s sqlite service over HTTP. The file stays on that container’s local filesystem, where SQLite’s locking works.

No. Everything here is SQLite’s file-level protocol. Run PostgreSQL or MySQL as services in containers and connect to them over the network.

What if my database gets really large (>10GB)?

Section titled “What if my database gets really large (>10GB)?”

Size is not the constraint the directory imposes; the container’s disk is. Keep databases under 100GB for reasonable maintenance times. For larger datasets, consider:

  • Sharding across multiple databases
  • Indexing aggressively
  • Moving to a container with more storage
  • Running a dedicated PostgreSQL container

hoody-sqlite opens its own connections in WAL, and journal_mode is a property of the database file, so a database it has opened stays in WAL for every later client. For a database you create yourself with another library, set it once:

PRAGMA journal_mode = WAL;

Does the HTTP API require /hoody/databases?

Section titled “Does the HTTP API require /hoody/databases?”

On a stock container, no. hoody-sqlite is started with --allow-any-absolute-db-path, so an absolute path elsewhere works:

Terminal window
# Conventional
curl "...?db=/hoody/databases/app.db"
# Also works on a stock container
curl "...?db=/home/user/data/test.db"

Without that flag the service refuses absolute paths outside /hoody/databases.


The error means a conflicting lock was not released in time — usually the busy timeout elapsing, though SQLite also returns it at once for a conflict that waiting cannot resolve. The usual causes:

  1. Long-running transactions:

    -- Wrong: holding the write lock too long
    BEGIN EXCLUSIVE;
    -- Complex operations taking seconds
    COMMIT;
    -- Correct: break into smaller transactions
    BEGIN; INSERT ...; COMMIT;
    BEGIN; INSERT ...; COMMIT;
  2. Busy timeout too low, or never set:

    conn = sqlite3.connect('/hoody/databases/app.db')
    conn.execute('PRAGMA busy_timeout = 30000') # 30 seconds
  3. Rollback journal instead of WAL:

    PRAGMA journal_mode = WAL;
  4. Sustained write concurrency: writes serialize, so past a certain rate the queue never drains. Batch them, or move to a database server.

For sqlite3: cannot open database, check the directory, create the database if it is missing, and check permissions:

Terminal window
# Verify directory exists
ls -la /hoody/databases/
# Create database if needed
sqlite3 /hoody/databases/newdb.db "CREATE TABLE test (id INTEGER)"
# Check permissions: the file must be readable and writable
# by the user of the process opening it
ls -la /hoody/databases/newdb.db
  1. Enable WAL mode:

    PRAGMA journal_mode = WAL;
    PRAGMA synchronous = NORMAL;
  2. Use indexes:

    CREATE INDEX idx_query ON table(column);
    ANALYZE;
  3. Increase cache size:

    PRAGMA cache_size = -64000; -- 64MB cache
  4. Batch operations:

    BEGIN;
    -- Multiple INSERTs
    COMMIT;

Under WAL, readers and one writer proceed together; a second writer waits on the write lock until the first commits or the busy timeout expires.

Time Process A Database Process B
─────────────────────────────────────────────────────────────────────
T1 BEGIN IMMEDIATE write lock -> A BEGIN IMMEDIATE
T2 INSERT INTO users WAL append waiting on the lock
T3 INSERT INTO posts WAL append waiting
T4 COMMIT lock released lock -> B
T5 (done) WAL append INSERT INTO logs
T6 SELECT (no wait) readers unblocked COMMIT

No reader appears in the waiting column above: that is what WAL buys against a writer. What it does not buy is parallel writes, on any path, and a reader can still wait for maintenance or for SQL that drops or alters a table, which take the database exclusively.


Storage:

Database access: