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:
That's it. The next time you deploy, AppMecca will:
- Create a dedicated database for your app on the shared managed PostgreSQL server
- Create a database user with full privileges on that database
- Generate a secure password and keep it in the platform secret store
- 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):
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:
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:
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 VIEWneeds the owner. SECURITY DEFINERfunctions 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_USERorDB_PASSWORDyou set withmecca var setoverrides 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 andmecca app execreceive the sameDB_*as your app — the runtime role. Route migrations there toDATABASE_OWNER_URLtoo.
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:
- Run migrations (if needed)
- 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:
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:
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¶
- Cache -- Enable Redis for caching and task queues
- Environment Variables -- How all environment variables work, including secrets
- Database Operations -- Backups, restores, and management
- Services -- Ports, health checks, and commands