Postgres connector
i18n as infrastructure
Point the Fora connector at your Postgres database with a config file. It creates a fora_translations table and optional locale-transparent views. Your app queries the views with a session variable — no other code changes required.
How it works
Your DB ←──── connector (your VPC) ────→ Fora API
↓
fora_translations
↓
products_localized (view)
↑
Your app queries hereThe connector runs entirely in your infrastructure. Your database credentials never leave your network. The only outbound connection is HTTPS to api.getfora.ai. Translated images are written directly from the Fora server to your S3 bucket via pre-signed PUT URLs.
1. Install the connector
Pull the connector image from GitHub Container Registry:
docker pull ghcr.io/getfora/fora-connector:latest
Works on Linux and macOS (Intel and Apple Silicon). No other dependencies required.
2. Configure it
Create fora.yaml next to your deployment config:
name: my-app
# Postgres DSN — connector needs SELECT on your tables,
# CREATE TABLE / CREATE VIEW on the schema.
connection_string: postgres://user:password@localhost:5432/myapp?sslmode=disable
config:
target_locales:
- de
- es
- fr
- it
- pt-BR
# Terms preserved untranslated across all tables (brand names, product codes, etc.)
protected_terms:
- Nike
- AirForce 1
# Creates <table>_localized views. Your app queries these instead of base tables.
create_views: true
tables:
- table: products
id_column: id
fields:
- name
- description
filter: "status = 'active'" # optional WHERE clause
- table: posts
id_column: id
fields:
- title
- body
image_fields:
- hero_image_url # requires s3 config below
# Required only if translating images
s3:
bucket: my-app-translations
region: us-east-1
# Credentials are optional — omit them if the connector runs on EC2, ECS, or EKS
# with an IAM role that has s3:PutObject on this bucket.
# access_key_id: AKIA...
# secret_access_key: ...3. Run the connector
docker run --rm \ -v $(pwd)/fora.yaml:/fora.yaml \ -e FORA_API_KEY=fora_live_abc123... \ ghcr.io/getfora/fora-connector:latest /fora.yaml
On first run the connector:
- Creates
fora_translationsin your database - Creates
products_localizedandposts_localizedviews - Translates all matching rows and writes results to the translations table
- Enters a poll loop (every 30 seconds) to pick up new rows automatically
level=INFO msg="provisioning schema" tables=2 locales=[de es fr it pt-BR] level=INFO msg="connector started" version=v0.2.0 poll_interval=30s level=INFO msg="translated row" table=products row_id=1 locale=es level=INFO msg="translated row" table=products row_id=1 locale=fr ...
Re-running is safe — only rows without a completed translation are processed.
4. Query localized content
The _localized views return translated content when fora.locale is set, and fall back to the original column value otherwise — so your app never returns a blank field for rows the connector hasn't processed yet. The variable must be set inside a transaction:
BEGIN; SET LOCAL fora.locale = 'es'; SELECT id, name, description FROM products_localized WHERE status = 'active'; COMMIT;
Go (pgx)
func withLocale(ctx context.Context, pool *pgxpool.Pool, locale string, fn func(pgx.Tx) error) error {
tx, err := pool.Begin(ctx)
if err != nil { return err }
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "SELECT set_config('fora.locale', $1, true)", locale); err != nil { return err }
if err := fn(tx); err != nil { return err }
return tx.Commit(ctx)
}Node.js (node-postgres)
async function withLocale(pool, locale, fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SELECT set_config('fora.locale', $1, true)", [locale]);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}Python (psycopg3)
from contextlib import contextmanager
@contextmanager
def locale_transaction(conn, locale):
with conn.transaction():
conn.execute("SELECT set_config('fora.locale', %s, true)", [locale])
yield conn
# Usage:
with locale_transaction(conn, request.locale) as c:
rows = c.execute("SELECT id, name FROM products_localized").fetchall()Ruby on Rails
def with_locale(locale, &block)
ApplicationRecord.transaction do
ApplicationRecord.connection.execute(
ApplicationRecord.sanitize_sql(["SET LOCAL fora.locale = ?", locale])
)
block.call
end
end
with_locale(I18n.locale) { Product.from("products_localized").active.all }Production deployment
For long-running deployments, add the connector as a service alongside your app:
services:
fora-connector:
image: ghcr.io/getfora/fora-connector:latest
environment:
FORA_API_KEY: ${FORA_API_KEY}
DATABASE_URL: postgres://user:password@db:5432/myapp
volumes:
- ./fora.yaml:/fora.yaml:ro
command: ["/fora.yaml"]
restart: unless-stoppedOn ECS or Kubernetes, run it as a sidecar or standalone service. The connector only needs outbound HTTPS to api.getfora.ai and access to your Postgres host.
Database permissions
-- Minimum required GRANT SELECT ON products, posts TO fora_connector; GRANT CREATE ON SCHEMA public TO fora_connector;
If you prefer not to grant CREATE, create the table manually first:
CREATE TABLE fora_translations (
table_name TEXT NOT NULL,
row_id TEXT NOT NULL,
locale TEXT NOT NULL,
fields JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
synced_at TIMESTAMPTZ,
PRIMARY KEY (table_name, row_id, locale)
);
CREATE INDEX fora_translations_pending
ON fora_translations (table_name, status)
WHERE status = 'pending';
GRANT INSERT, UPDATE, SELECT ON fora_translations TO fora_connector;Configuration reference
| Field | Req | Description |
|---|---|---|
| name | No | Identifier shown in logs |
| connection_string | Yes | Postgres DSN. Overridden by DATABASE_URL env var |
| config.target_locales | Yes | BCP-47 codes: de, es, fr, it, pt-BR |
| config.protected_terms | No | Terms to preserve untranslated across all tables — brand names, product codes, etc. Max 50, 100 chars each. |
| config.create_views | No | Create <table>_localized views (default: false) |
| config.tables[].table | Yes | Table name |
| config.tables[].id_column | Yes | Primary key column |
| config.tables[].fields | Yes | Text columns to translate |
| config.tables[].image_fields | No | Image URL columns (requires s3 config) |
| config.tables[].filter | No | SQL WHERE clause to scope rows |
| s3.bucket | No | S3 bucket name (required for image translation) |
| s3.region | No | AWS region (default: us-east-1) |
| s3.access_key_id | No | AWS access key. Omit when running on EC2/ECS/EKS with an IAM role — the standard credential chain is used automatically. |
| s3.secret_access_key | No | AWS secret key. Never commit to source control — use an IAM role or pass via environment variable instead. |
| s3.key_prefix | No | Key prefix for translated image objects |
| s3.endpoint | No | Custom endpoint for R2 / MinIO |
| s3.public_url | No | CDN base URL (required for custom endpoints) |
Environment variables
| Field | Req | Description |
|---|---|---|
| FORA_API_KEY | Yes | API key |
| DATABASE_URL | No | Overrides connection_string in config file |
| FORA_API_URL | No | Override API base URL (default: https://api.getfora.ai) |
| POLL_INTERVAL | No | Seconds between sync runs (default: 30) |