Skip to content

Database

AppMecca can automatically provision a PostgreSQL database for your application. You enable it with a single line in appmecca.yaml, and AppMecca handles everything else: creating the database, generating credentials, and injecting connection details into your containers as environment variables.

Enabling the Database

Add this to your appmecca.yaml:

database:
  enabled: true

That's it. The next time you deploy, AppMecca will:

  1. Create a dedicated database for your app on the shared managed PostgreSQL server
  2. Create a database user with full privileges on that database
  3. Generate a secure password and keep it in the platform secret store
  4. Inject 6 environment variables into every container in your deployment

There are no mecca db commands

Database lifecycle is entirely implicit. The database is provisioned on your first deploy once enabled: true is set, and dropped when you run mecca app delete. There is nothing to create by hand. For restores, see Database Backups below.

Connection Pool Budget

Every database-enabled app declares the maximum number of PostgreSQL connections its containers may open. AppMecca uses this number to protect the shared database server from connection exhaustion: before a deploy is claimed, the platform checks that admitting it won't push the fleet's declared total past 70% of the server's connection limit. If it would, the deploy is held in the queue with a clear reason — your app keeps running on its old version until budget frees up.

Add max_connections to the database block (the default is 5):

database:
  enabled: true
  max_connections: 5    # default — fits most sync framework pools

The number is per replica. If your app declares replicas: 3 for its database-using service, the platform reserves 5 × 3 = 15 connections in the budget.

What to set it to

The default of 5 works for most apps. Tune to match your framework's pool config. A few common cases:

Framework Recommended max_connections Why
Django (gunicorn) 2-3 per worker Each worker opens its own connection. Total = workers × 2-3.
Spring Boot (Hikari) 3-5 Set via spring.datasource.hikari.maximum-pool-size. The HikariPool default of 10 is too aggressive for a shared server.
Node.js (pg.Pool) 3-5 Set via new Pool({ max: 5 }).
FastAPI + asyncpg 5 Set via asyncpg.create_pool(max_size=5).
Celery worker 3 per worker Match the framework's connection count for the worker container.

The accepted range is 1-50. If you genuinely need more than 50 connections per replica, the right move is to ask your operator for a larger database server — not to raise the per-app budget.

max_connections must be a plain integer from 1 to 50. Strings (including quoted numbers such as "10"), decimals (including 10.0), booleans (true, yes) and null are refused when your repository is checked and at build time, with the message database.max_connections must be integer 1-50. The database: block itself must be a mapping. Write the number without quotes:

database:
  enabled: true
  max_connections: 10   # not "10", not 10.0

What happens if your deploy is held

When the admission gate refuses to claim a deploy, your app continues to run on its previous version. The deploy waits in the queue until other deploys complete and free up budget. To investigate:

mecca queue list -e dev          # See the Reason column on your queued action
mecca queue status <action-id>   # Full row with admission_check_reason

The reason looks like: db_capacity: 50/60 declared, this app needs 15 more (would be 65). Tune via 'database.max_connections' in appmecca.yaml or wait for in-flight deploys to complete.

If your deploy is consistently held, either reduce your declared budget (and your framework's pool size to match), or ask your operator for a larger database server.

What Gets Created

When you enable the database, AppMecca generates the database name and user for you. You do not choose these, and you should never hard-code them — read them from the injected environment variables instead.

What How it is generated Example
Database name Opaque, derived from app + branch am_9f3c1a7b42de_db
Database user Opaque, derived from app + branch am_9f3c1a7b42de_user
Password Randomly generated Auto-generated

The name is a hash, not your app name

A common wrong assumption is that the database is called {your_app}_db. It is not. Names are always an opaque am_<hash>_db / am_<hash>_user derived from the app name and the branch — there is no readable form, and the hash changes per branch.

Never write the database name into your config, your migrations, or a psql command. Always read DATABASE_URL (or DB_NAME / DB_USER) from the environment.

Branch isolation

Because the name is derived from the branch as well as the app, each branch deployment gets its own completely separate database. Deploying feature/payments creates a fresh database isolated from main. See Branch Deployments for details.

Injected Environment Variables

When database.enabled: true, AppMecca injects these 6 environment variables into every container in your deployment:

Variable Example Value Description
DATABASE_URL postgresql://am_9f3c1a7b42de_user:abc123@db-host:5432/am_9f3c1a7b42de_db?sslmode=require&sslrootcert=/etc/appmecca/rds-ca.pem Full connection URL (most frameworks use this)
DB_HOST db-host.internal Database hostname
DB_PORT 5432 Database port
DB_NAME am_9f3c1a7b42de_db Database name
DB_USER am_9f3c1a7b42de_user Database username
DB_PASSWORD abc123... Database password
APPMECCA_RDS_CA_PATH /etc/appmecca/rds-ca.pem Where the database's CA certificate is mounted

These are available in all services defined in your deploy section — web servers, workers, schedulers, and any other service.

Use DATABASE_URL when possible

Most frameworks (Django, Flask/SQLAlchemy, Rails, Prisma, etc.) can read DATABASE_URL directly. This is the simplest way to configure your database connection. The individual DB_* variables are provided for frameworks that need them separately.

TLS: what the connection URL asks for, and why

The connection to your database is always encrypted, and the URL carries two TLS settings:

  • sslmode=require — the connection must be encrypted.
  • sslrootcert=/etc/appmecca/rds-ca.pem — the certificate authority to check the database server against. AppMecca mounts that file into every container, read-only. It is Amazon's public RDS certificate bundle; there is nothing secret in it, and your app only ever reads it.

You do not have to do anything to use this — it is what DATABASE_URL already says. APPMECCA_RDS_CA_PATH holds the same path for apps that build their own connection string or need the certificate for other tooling.

Do not 'fix' a TLS error with sslmode=disable

If a TLS error sends you looking for a workaround, sslmode=disable will appear to solve it. It does not solve it — it turns off encryption to the database, silently, and everything keeps working, so nothing tells you afterwards. If a connection is failing, keep sslmode=require and check that your driver is being pointed at sslrootcert (or APPMECCA_RDS_CA_PATH).

If you are on Node and see self-signed certificate in certificate chain

Recent node-postgres versions treat sslmode=require as "verify the server's certificate" rather than "just encrypt". That needs a certificate to verify against, which is exactly what sslrootcert in your DATABASE_URL now supplies. If you see this error, redeploy — the setting is applied when your container starts, so an app deployed before this change keeps its old URL until its next deploy.

A Separate Runtime Role, for Row-Level Security

By default your app gets one database user, and it owns every table your migrations create. PostgreSQL does not apply row-level security (RLS) policies to a table's owner, so if your app relies on RLS to keep tenants apart, its own queries are not bound by the policies.

Making the policies bind the owner (ALTER TABLE … FORCE ROW LEVEL SECURITY) does not work on AppMecca: the platform backs your database up before every deploy by running pg_dump as the owner, and PostgreSQL refuses that dump when a policy applies to it. Every deploy after the first then fails with query would be affected by row-level security policy.

Opt in to a second, non-owner role instead:

database:
  enabled: true
  runtime_role: true

Your app then has two roles:

Role What it can do Environment variables
Runtime (am_<hash>_rt) Read and write rows (SELECT, INSERT, UPDATE, DELETE) on every table in the public schema, use its sequences and call its functions. It cannot create, alter or drop anything, cannot TRUNCATE, and RLS policies bind it without FORCE. DATABASE_URL, DB_USER, DB_PASSWORD
Owner (am_<hash>_user) Everything, as before: migrations, DDL, policies. The platform's backups run as this role, and they keep working because RLS no longer has to be forced. DATABASE_OWNER_URL, DB_OWNER_USER, DB_OWNER_PASSWORD

DB_HOST, DB_PORT and DB_NAME are the same for both. The ${database_url} and ${db_user} templates resolve to the runtime role, like DATABASE_URL.

Route your migrations to the owner. Run them with DATABASE_OWNER_URL (for example, DATABASE_URL="$DATABASE_OWNER_URL" python manage.py migrate in your entrypoint). If you forget, the migration fails at boot with permission denied — deliberately loud, rather than your app silently running as the owner with its policies never applied.

Tables, partitions, sequences and functions the owner creates later — your migrations on the next boot, or partitions your app creates at runtime — are usable by the runtime role straight away. Only the public schema is covered.

This is least privilege by default, not a sandbox

The owner's password is injected into every container too, so your migrations can run. The runtime role makes your app's ordinary queries run without ownership and lets RLS bind them; it does not protect you if your container itself is compromised, because the owner's credentials are in the same environment.

Connection budget

database.max_connections must cover both pools — the runtime pool your app serves with and whatever the owner connection opens for migrations. The platform counts the declared budget per app, not per role.

The role is created, and its grants re-applied, on every deploy before any container starts, and again after a restore. Each branch database gets its own runtime role, and deleting the app drops both roles with the database. It needs PostgreSQL 18, which every AppMecca environment runs.

The runtime role may also call setval on your sequences — fixture loads and bulk loaders do this after a COPY, so it is allowed by design. It only ever affects your own app's data.

Large objects (lo_create, lo_import, lo_from_bytea) created by the runtime role are readable by the owner automatically, so they are included in every backup.

Restoring over large objects. A restore removes your app's large objects, both the owner's and the runtime role's, and then loads the backup's. Afterwards every restored large object belongs to the owner. The runtime role can still read and write them, but it cannot delete them: lo_unlink on a restored object fails with must be owner of large object. So after any restore, an app that deletes large objects from its runtime connection fails on the restored ones, and so does the lo extension's lo_manage trigger when a row that references one is updated or deleted. Delete restored large objects through DATABASE_OWNER_URL. Large objects the runtime role creates after the restore are its own, and it can delete those. As the database operations page explains, a restore that fails after the backup's large objects are loaded can leave part of it applied, and its error says so.

To do this, the platform lets the owner switch to the runtime role (SET ROLE) without gaining its privileges. That also lets the owner give objects to the runtime role (ALTER … OWNER TO). Do not: the next deploy is refused while the runtime role owns a table or any other object in public. A large object given to it is treated like one the runtime role created.

After a restore, large objects are readable only by the owner

With runtime_role: true, a restore currently leaves every large object owned by the owner, with no grants for the runtime role. Your app, which connects as the runtime role, then can't read or delete the restored large objects. This is being fixed. Until then, don't opt in to runtime_role if your app relies on large objects you may need to restore. If you have already opted in, opting out gives your app the owner again from its next deploy.

Only objects your owner owns get grants. Objects in public that the platform created for you — for example an extension such as pg_stat_statements restored from an uploaded dump — are left alone, and the runtime role gets no privileges on them.

What still runs as the owner

Row-level security binds the runtime role's own queries. These still run with the owner's rights, so a policy does not bind them:

  • Views, unless you create them with security_invoker = true (CREATE VIEW … WITH (security_invoker = true) AS …). A plain view reads its tables as the view's owner.
  • Materialized views. They carry every tenant's rows, and REFRESH MATERIALIZED VIEW needs the owner.
  • SECURITY DEFINER functions you write. The runtime role can call them, and they run as the owner, with row-level security off.
  • Variables you set yourself. A DATABASE_URL, DB_USER or DB_PASSWORD you set with mecca var set overrides the runtime role, so your app connects as whatever it names. The deploy logs a warning naming the variables; unset them for row-level security to bind.
  • Your release: command and mecca app exec receive the same DB_* as your app — the runtime role. Route migrations there to DATABASE_OWNER_URL too.

Your environment must run platform release 6.2 or later

An older platform reads runtime_role: true and ignores it: your app then deploys and runs as the owner, with its policies never applied, and nothing tells you. Ask your operator before relying on it.

Opting out

Remove runtime_role: true (or set it to false) and, in the same change, revert your migration routing: DATABASE_OWNER_URL is no longer injected, so DATABASE_URL="$DATABASE_OWNER_URL" python manage.py migrate would migrate against an empty URL. Use python manage.py migrate again.

Your containers get the owner from the next deploy. The runtime role and its grants stay until you delete the app, but the platform disables its login once no container of your app uses it any more. A restore leaves it disabled; opting back in re-enables it.

Handling Migrations

AppMecca does not run database migrations for you. Migrations are your application's responsibility, and the recommended approach is to handle them in your container's entrypoint script. This gives you full control over when and how migrations run.

The Entrypoint Pattern

The entrypoint script runs every time your container starts. It should:

  1. Run migrations (if needed)
  2. Then start the application

This way, migrations run automatically on every deployment.

Django Example

A complete example for a Django application:

appmecca.yaml:

containers:
  - name: myapp
    dockerfile: Dockerfile
    context: .

deploy:
  - name: web
    container: myapp
    port: 8000
    health_check:
      path: /health
    environment:
      DJANGO_SETTINGS_MODULE: myapp.settings.production

  - name: worker
    container: myapp
    command: "celery -A myapp worker -l info"

database:
  enabled: true

entrypoint.sh:

#!/bin/bash
set -e

echo "Running database migrations..."
python manage.py migrate --noinput

echo "Collecting static files..."
python manage.py collectstatic --noinput

echo "Starting application..."
exec "$@"

Dockerfile:

FROM python:3.12-slim
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "myapp.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]

settings/production.py:

import os

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": os.environ["DB_HOST"],
        "PORT": os.environ["DB_PORT"],
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
    }
}

Or, more concisely using DATABASE_URL with dj-database-url:

import dj_database_url

DATABASES = {
    "default": dj_database_url.config(default=os.environ.get("DATABASE_URL"))
}

Migrations and workers

If you have both a web service and a worker service using the same container image, both will run the entrypoint script. To avoid running migrations twice, add a guard to your entrypoint:

#!/bin/bash
set -e

# Only run migrations for the web service
if [ "$APPMECCA_SERVICE" = "web" ]; then
    echo "Running migrations..."
    python manage.py migrate --noinput
fi

exec "$@"

The APPMECCA_SERVICE variable is automatically set to the service name from your appmecca.yaml (e.g., web, worker).

Flask / SQLAlchemy Example

A complete example for a Flask app using SQLAlchemy and Alembic:

appmecca.yaml:

containers:
  - name: flask-api
    dockerfile: Dockerfile
    context: .

deploy:
  - name: web
    container: flask-api
    port: 5000
    health_check:
      path: /health

database:
  enabled: true

entrypoint.sh:

#!/bin/bash
set -e

echo "Running database migrations..."
flask db upgrade

echo "Starting application..."
exec "$@"

config.py:

import os

class ProductionConfig:
    SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
    SQLALCHEMY_TRACK_MODIFICATIONS = False

Dockerfile:

FROM python:3.12-slim
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

EXPOSE 5000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:create_app()"]

Node.js / Prisma Example

For a Node.js application using Prisma:

entrypoint.sh:

#!/bin/bash
set -e

echo "Running database migrations..."
npx prisma migrate deploy

echo "Starting application..."
exec "$@"

prisma/schema.prisma:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

Using Credential Templates

If you need to pass database connection details to specific environment variables (for example, a framework that expects POSTGRES_HOST instead of DB_HOST), you can use credential templates in your appmecca.yaml:

deploy:
  - name: web
    container: myapp
    port: 8000
    environment:
      POSTGRES_HOST: "${db_host}"
      POSTGRES_PORT: "${db_port}"
      POSTGRES_DB: "${db_name}"
      POSTGRES_USER: "${db_user}"
      POSTGRES_PASSWORD: "${db_password}"
      POSTGRES_URI: "${database_url}"

database:
  enabled: true

These templates are resolved at deploy time to the actual credential values. See Environment Variables for the full list of available templates.

Templates vs. direct variables

You don't need templates if your app reads the standard DATABASE_URL, DB_HOST, etc. variables. Templates are useful when your framework or library expects different variable names.

Rotating Credentials

If a DATABASE_URL has leaked — pasted into a log, a ticket, a screenshot — replace it:

mecca app rotate-credentials myapp -b main              # database and cache
mecca app rotate-credentials myapp -b main --store db   # the database only
mecca app rotate-credentials myapp -b main --wait       # and follow it to the end

Rotation acts on one branch (each branch has its own database role). It asks you to type the app name back, naming the branch and what it will rotate. It needs the ADMINISTRATOR or OWNER role in the app's group and a signed-in session — an API key is refused.

This revokes. When the rotation completes, PostgreSQL refuses the old password for every new connection. Connections your app already holds stay open — PostgreSQL checks a password only when a connection is opened — so a running app keeps serving, but a connection pool that reconnects, or a worker that starts, fails until your containers have the new password. AppMecca queues a redeploy of the release the branch is running when that redeploy starts (never your latest build, so a rollback survives a rotation, and a release you deployed while the rotation ran is kept) to hand them the new one; with --wait the command follows that redeploy too.

If your app uses the separate runtime role, both passwords are rotated — the owner's and the runtime role's — because DATABASE_URL connects as the runtime role.

--wait reports each credential's outcome:

Outcome Meaning
rotated The old password is refused.
rotated, still applying Cache only. ElastiCache accepted the new password and is still applying it (this can take several minutes). It refuses the old password once that finishes. There is nothing to re-run, and the command exits successfully.
NOT rotated Nothing changed (the reason is shown) — run it again later.
half-rotated Part of the change landed and part did not. Run the same command again: rotation needs no old password, so re-running repairs it.

If the redeploy fails, the command exits with an error but says so plainly: the password did rotate; only your containers are still on the old one. Run mecca app redeploy myapp -b main.

Database Backups

A backup is a bundle — your database plus every declared drive, captured together. Manage them with the mecca app commands:

# Create a backup
mecca app backup my-blog -e dev

# Create a backup with a description
mecca app backup my-blog -e dev --description "before v2.0 release"

# List backups (each row shows the bundle prefix you pass below)
mecca app backups my-blog -e dev

# Restore from a bundle
mecca app restore my-blog -e dev <bundle-prefix> --force

# Get download links for everything in a bundle
mecca app download my-blog -e dev <bundle-prefix>

These commands accept -b for branch-scoped operations:

# Backup a branch's database
mecca app backup my-blog -e dev -b feature/payments

# Cross-branch restore: clone main's data into a feature branch
mecca app restore my-blog -e dev -b feature/payments <bundle-prefix-from-main> --force

Automatic safety copies expire

Besides the bundles you take, the platform takes a database-only safety copy before every deploy, before a database-only restore, and before a delete drops the database. They show in mecca app backups as database-only rows, they restore and download like a bundle (mecca app download prints one link to the .sql.gz), and they are not kept for ever:

Safety copy Kept for
before a deploy (pre-deploy) may be removed 14 days after it was taken
before a database-only restore (pre-restore) may be removed 30 days after it was taken if that restore succeeded — kept until you delete it if it failed or did not finish; a failed restore also keeps the backup it restored from
before a delete drops the database (pre-drop) kept until you delete it

Take mecca app backup for a copy you keep — your own bundles are never removed by this. A safety copy can be deleted like any backup: mecca app backup-delete <key> -e dev, where <key> is the .sql.gz prefix shown by mecca app backups. A bundle whose backup failed after its database dump was uploaded (no manifest was written) lists as an incomplete bundle: it downloads and deletes like any bundle, and cannot be restored.

Bringing in an existing database

To load a database you already have — a dump from another host, or a colleague's export — upload it directly:

# Dashboard → your app → Database tab → choose the file → confirm

Uploading a dump is destructive: it replaces the app's current database contents. It takes a safety backup first so the change can be rolled back. A custom-format dump (pg_dump -Fc) is recommended; a gzipped plain-SQL dump also works.

See Database Operations for the full backup and restore guide.

Complete Example

Here is a full appmecca.yaml for a Django app with a database, a Celery worker, and Redis caching:

containers:
  - name: myapp
    dockerfile: Dockerfile
    context: .

deploy:
  - name: web
    container: myapp
    port: 8000
    command: "gunicorn myapp.wsgi:application -w 4 -b 0.0.0.0:8000"
    health_check:
      path: /health
    environment:
      DJANGO_SETTINGS_MODULE: myapp.settings.production

  - name: worker
    container: myapp
    command: "celery -A myapp worker -l info"

  - name: scheduler
    container: myapp
    command: "celery -A myapp beat -l info"

database:
  enabled: true

redis:
  enabled: true

All three services (web, worker, scheduler) receive the same DATABASE_URL, REDIS_URL, and other injected variables automatically.

Next Steps