Addons¶
Addons are backing services — databases, caches, object storage — that your app depends on. Hop3 manages their lifecycle (create, attach, detach, destroy) and injects the connection details into your app as environment variables on deploy.
This guide covers the four addons shipped in Hop3 0.5 — postgres, mysql, redis, and s3 — plus the experimental email (SMTP relay) addon added in 0.6.1.
Quick Start¶
The simplest way to use an addon is to declare it in hop3.toml. On first deploy, the addon is auto-provisioned; on subsequent deploys it's reused.
[metadata]
id = "my-django-app"
[run]
start = "gunicorn myapp.wsgi --bind 0.0.0.0:$PORT"
[[addons]]
type = "postgres"
[[addons]]
type = "redis"
After hop3 deploy, your app has DATABASE_URL and REDIS_URL in its environment — no further wiring needed.
CLI Workflow¶
When you need imperative control (multi-app sharing, ad-hoc provisioning, destruction), use the addon commands.
Create an addon¶
hop3 addon create postgres my-database
hop3 addon create redis my-cache
hop3 addon create mysql legacy-db
hop3 addon create s3 uploads
The addon name is free-form; pick one that describes the data, not the app. This keeps the name stable across apps that share the addon.
Attach to an app¶
Attach injects the addon's env vars into the app on the next deploy or restart. An addon can be attached to multiple apps (shared database pattern).
Detach and destroy¶
hop3 addon detach my-database --app my-app # Remove env vars; keep data
hop3 addon destroy my-database # Permanently delete (prompts)
destroy requires typed confirmation of the addon name. Pass --confirm=<name> to skip the interactive prompt in scripts. --force skips all safety checks.
List and inspect¶
hop3 addon list # All provisioned instances (alias: addons)
hop3 addon list --app my-app # Only addons attached to my-app
hop3 addon types # Addon types you can create
hop3 addon show my-database # Full details for one addon
hop3 addon status my-database # Health and connection check
Type-specific commands¶
Each addon type adds a few operations under hop3 addon <type> <verb> <name> (the type is part of the command path, so no --type flag):
# Credentials (all types) — prints the connection env vars; treat as sensitive
hop3 addon postgres credentials my-database
hop3 addon redis credentials my-cache
# Dump / restore (all four types support both)
hop3 addon postgres dump my-database # pg_dump → server backup dir
hop3 addon postgres restore my-database <path> # psql restore (prompts)
hop3 addon mysql dump legacy-db
hop3 addon redis dump my-cache
hop3 addon redis restore my-cache <path>
# Postgres extensions (allow-listed)
hop3 addon postgres extensions my-database postgis pgvector
# Redis flush — empties the database, keeps the addon (prompts)
hop3 addon redis flush my-cache
# Ad-hoc query — runs as the addon's own (least-privilege) user
hop3 addon postgres query my-database --command "SELECT count(*) FROM users"
hop3 addon redis query my-cache --command "DBSIZE"
See the CLI reference for the full per-type verb matrix.
Addon Reference¶
postgres¶
Provisions a PostgreSQL database owned by a per-app user with CREATE ON DATABASE + CREATE, USAGE ON SCHEMA public grants, so migrations can install trusted extensions (pg_trgm, uuid-ossp, citext) without superuser help.
Injected env vars:
| Variable | Example |
|---|---|
DATABASE_URL |
postgres://myapp:pass@localhost:5432/myapp |
PGDATABASE |
myapp |
PGUSER |
myapp |
PGPASSWORD |
(generated) |
PGHOST |
localhost |
PGPORT |
5432 |
Non-trusted extensions (e.g. postgis, pgvector, bloom) cannot be installed by the per-app user. Declare them in hop3.toml and Hop3 will install them as superuser at provisioning time:
Hop3 enforces an allow-list. The default set covers the PG13+ trusted extensions (pg_trgm, hstore, citext, pgcrypto, uuid-ossp, …) plus widely-used non-trusted ones audited to carry no privilege-escalation surface (postgis, pgvector, bloom, cube, earthdistance, ip4r, pg_stat_statements). To enable an additional extension on a specific Hop3 install — e.g. pg_partman or a vendor extension — set the operator-side env var HOP3_EXTRA_PG_EXTENSIONS (comma-separated). A small hard-deny set (postgres_fdw, dblink, file_fdw, adminpack, untrusted PL languages) cannot be enabled even via the override; those grant filesystem / network / arbitrary-code-execution capability and would defeat the separation between "deploy an app" and "execute as the postgres superuser".
Some extensions (
pg_cron,timescaledb) requireshared_preload_librariesand a Postgres restart on top ofCREATE EXTENSION. Hop3's default installer does not yet pre-load arbitrary extensions; treat those as a separate setup step.
mysql¶
Injected env vars:
| Variable | Example |
|---|---|
DATABASE_URL |
mysql://myapp:pass@localhost:3306/myapp |
MYSQL_DATABASE |
myapp |
MYSQL_USER |
myapp |
MYSQL_PASSWORD |
(generated) |
MYSQL_HOST |
localhost |
MYSQL_PORT |
3306 |
Docker apps: Hop3 grants MySQL access on multiple host patterns (@'localhost', @'127.0.0.1', @'10.%', @'172.%', @'192.168.%') so apps reaching the host MySQL from any of the private ranges a Docker network might use authenticate correctly. This is automatic, but it's worth knowing if you inspect the mysql.user table.
redis¶
A logical Redis database (numeric, 0–15 by default) is allocated per addon instance; multiple addons can coexist on the same Redis server.
Injected env vars:
| Variable | Example |
|---|---|
REDIS_URL |
redis://127.0.0.1:6379/2 |
REDIS_HOST |
127.0.0.1 |
REDIS_PORT |
6379 |
REDIS_DB |
2 |
Note: REDIS_HOST is 127.0.0.1 (not localhost) to avoid IPv6 resolution issues. For Docker apps, the Docker deployer rewrites 127.0.0.1 → host.docker.internal at deploy time.
s3¶
Provisions a bucket (named hop3-<addon-name>) and a scoped access key on the configured S3 backend (MinIO in 0.5; Garage planned for 0.6).
Injected env vars:
| Variable | Example |
|---|---|
S3_ENDPOINT |
http://localhost:9000 |
S3_BUCKET |
hop3-uploads |
S3_ACCESS_KEY |
(generated) |
S3_SECRET_KEY |
(generated) |
S3_REGION |
us-east-1 |
S3_USE_PATH_STYLE |
true |
Path-style URLs are required for MinIO; virtual-host style will arrive with the Garage backend.
email (experimental)¶
Experimental (added 0.6.1; backend model reworked in 0.7). The command surface is subject to change. Every
addon email/server emailcommand prints a one-line experimental banner.
Email is a backing service with a swappable backend, like the database addons. You pick a backend once at the server level; an app opts in by attaching an email addon and sends over a platform-owned loopback SMTP endpoint (127.0.0.1:25) — a queuing Postfix relay that forwards to whatever the backend is. Apps never shell sendmail (a local-binary side-channel isn't config-driven), and the provider credential never enters an app's environment. It is outbound transactional email only: no inbound, IMAP, or MX.
Install the local relay with the email feature (it's in --with all):
Pick a backend (admin-only):
relay— submit to a provider or corporate smarthost (Scaleway TEM, SES, SendGrid, Mailgun, Brevo, …); the provider owns deliverability. The recommended production backend — also spelledserver email set.catch— a local dev sink: mail is captured, never sent. The safe non-production default.direct(preview) — a Hop3-run MTA that delivers to recipients itself and signs with DKIM, no third party (the sovereign path). Hop3 generates the DKIM keypair and prints the SPF/DKIM/DMARC records to publish. Preview in 0.7: it needs a systemd host with outbound port 25 unblocked (it fails loud otherwise), and fresh-IP reputation is your responsibility — full support lands in 0.8.
Name a known provider with --provider and Hop3 fills the SMTP host/port (--list-providers: Resend, Postmark, Brevo, Mailgun / Mailgun-EU, Scaleway TEM; EU-hosted ones flagged):
hop3 server email backend relay --provider brevo \
--smtp-user <user> --smtp-password @./smtp.secret \
--from-domain example.com
# or spell out the endpoint yourself:
hop3 server email backend relay --smtp-host smtp.example.com --smtp-user u \
--smtp-password @./pw --from-domain example.com --dkim-selector s1
# a dev sink — nothing is sent:
hop3 server email backend catch --from-domain example.com
# a self-hosted MTA — prints the DKIM/SPF/DMARC records to publish:
hop3 server email backend direct --from-domain example.com
hop3 server email status # active backend + a live SPF/DKIM/DMARC pre-flight
Keep the password out of your shell history (ADR 036): --smtp-password @<path> reads it from a file, --smtp-password - reads it from stdin. Selecting a backend is idempotent and re-pointable — swapping it never re-touches an app.
Attach it to an app. An app declares it needs email by creating an addon without --smtp-* (so it inherits the backend) and attaching it (the type isn't inferred from the name, so pass --type email):
hop3 addon email create mail --from noreply@example.com # inherits the backend
hop3 addon attach mail --app my-app --type email # injects SMTP_HOST=127.0.0.1
hop3 addon email status mail # never the password
The app is injected SMTP_HOST=127.0.0.1 / SMTP_PORT=25 (no auth) and relays through the loopback Postfix; the provider credential stays in Postfix. Inheriting is refused if --from is not on the backend's verified sending domain. An app can bring its own provider by passing --smtp-* (all three creds or none), which sends to that provider directly instead of the shared relay.
Get alerted when things break. Reuse the backend to email yourself on server events (cert-renewal failures and failed deploys today; more with the monitoring roadmap) — opt-in, admin-only. The recipient defaults to your ACME email:
hop3 server email notifications on --to ops@example.com
hop3 server email notifications test # send a test message now
hop3 server email notifications status # enabled? and actually deliverable?
status tells you loudly if notifications are on but can't send (no backend / no recipient), so a misconfigured alert channel never stays silently broken.
Injected env vars — one endpoint, every common spelling. An inheriting app points at the loopback (SMTP_HOST=127.0.0.1, SMTP_PORT=25, no auth); an own-provider app points at its provider directly:
| Variables | Consumer |
|---|---|
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_TLS |
neutral / Node |
SMTP_URL (smtp://…) |
Node / nodemailer, URL parsers |
EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_USE_TLS, EMAIL_USE_SSL, DEFAULT_FROM_EMAIL |
Django (django.core.mail) |
MAIL_SERVER, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD, MAIL_USE_TLS, MAIL_USE_SSL, MAIL_DEFAULT_SENDER |
Flask-Mail |
So Django, Flask-Mail, and Node/nodemailer read their native names directly — no remapping.
Frameworks that read no SMTP env are pointed at the same 127.0.0.1:25 endpoint through their own config — never sendmail:
- Rails (ActionMailer) — in
config/environments/production.rb, read the injected vars: - WordPress — stock
wp_mail()uses PHPmail(). Hop3's WordPress packaging drops a must-use plugin (aphpmailer_inithook) that routeswp_mail()through the attached email addon'sSMTP_HOST/SMTP_PORTautomatically, and self-disables when no email addon is attached. For a custom app, write the equivalent frombefore-run— neversendmail.
Email is configured imperatively, so don't declare it in
[[addons]]: a declarative block has no credentials to provision, and the deploy fails loud telling you to runaddon email create.
Deliverability is your job, at the provider¶
For a relay backend Hop3 hands the message to your provider; for direct it delivers itself. Either way, whether it reaches the inbox is gated by DNS on your sending domain, not by Hop3:
- Publish SPF, DKIM, and DMARC for the From-domain (for a relay, your provider's dashboard generates the exact records; for direct, Hop3 generates the DKIM keypair and prints the records to publish). Since 2024, Gmail/Yahoo/Microsoft reject or spam-folder unauthenticated mail.
- The unit you authenticate is the domain, not the address. Once
example.comis verified, any From on it —noreply@,support@,billing@— sends for free. - Sending as
support@example.comdoes not mean Hop3 hosts that mailbox; replies follow your domain's existing MX (Google Workspace, etc.).
server email status checks SPF and DMARC for the domain via DNS and reports what's missing — never claiming "ready" over unpublished records. DKIM is auto-verified too once its selector is known: pass --dkim-selector <sel> (copy it from your provider's dashboard), or use --provider resend (Resend's selector is fixed, so it's supplied automatically). Without a selector the DKIM row stays guidance — Hop3 never guesses a selector and reports a fake "missing".
Wiring it into apps (recipes)¶
Attaching the addon injects the superset above. A few apps read those names directly; most read their own names and need a short [env] remap (Hop3's ${VAR} interpolation, resolved after addon injection). The injected values follow the backend the app inherits — for the shared backends that's the loopback (127.0.0.1:25, no auth, no TLS; the local relay does auth/TLS upstream); for an own---smtp-* app it's your provider (587/STARTTLS + creds). Two recurring gotchas:
- TLS / auth flags: toward the loopback, keep encryption and auth off (
SMTP_TLSis injectedfalse,SMTP_USER/SMTP_PASSWORDempty) — an app that hardcodes STARTTLS/465 or requires a username toward127.0.0.1will fail. For an own-provider app,SMTP_TLSmeans STARTTLS on 587; an implicit-TLS/465 or enum flag (SMTP_SECURITY, …) must be set literally, not piped fromSMTP_TLS. - Combined host:port: Grafana and Gitea want one
host:portstring — splice it with[env.computed](e.g.GF_SMTP_HOST = "${SMTP_HOST}:${SMTP_PORT}").
Works on attach (no remap): BookWyrm, Bugsink (Django EMAIL_*).
Laravel (Monica, BookStack, Invoice Ninja):
[env]
MAIL_MAILER = "smtp"
MAIL_HOST = "${SMTP_HOST}"
MAIL_PORT = "${SMTP_PORT}"
MAIL_USERNAME = "${SMTP_USER}" # empty toward the loopback backend
MAIL_PASSWORD = "${SMTP_PASSWORD}" # empty toward the loopback backend
MAIL_ENCRYPTION = "" # empty for the loopback; "tls" for an own-provider (587)
MAIL_FROM_ADDRESS = "${SMTP_FROM}" # BookStack uses MAIL_FROM instead
Bespoke-prefix apps — the [env] lines beyond a bare attach:
| App | [env] remap (values are ${SMTP_*} unless quoted literal) |
|---|---|
| GoToSocial | GTS_SMTP_HOST, GTS_SMTP_PORT, GTS_SMTP_USERNAME=${SMTP_USER}, GTS_SMTP_PASSWORD, GTS_SMTP_FROM |
| Vikunja | VIKUNJA_MAILER_ENABLED="true", _HOST, _PORT, _USERNAME=${SMTP_USER}, _PASSWORD, _FROMEMAIL=${SMTP_FROM} |
| Forgejo / Gitea | GITEA__mailer__ENABLED="true", __PROTOCOL="smtp+starttls", __SMTP_ADDR=${SMTP_HOST}, __SMTP_PORT, __USER, __PASSWD=${SMTP_PASSWORD}, __FROM=${SMTP_FROM} |
| Discourse | DISCOURSE_SMTP_ADDRESS=${SMTP_HOST}, _PORT, _USER_NAME=${SMTP_USER}, _PASSWORD, _ENABLE_START_TLS="true", DISCOURSE_NOTIFICATION_EMAIL=${SMTP_FROM} |
| Mattermost | MM_EMAILSETTINGS_SMTPSERVER=${SMTP_HOST}, _SMTPPORT, _SMTPUSERNAME=${SMTP_USER}, _SMTPPASSWORD, _ENABLESMTPAUTH="true", _CONNECTIONSECURITY="STARTTLS", _SENDEMAILNOTIFICATIONS="true", _FEEDBACKEMAIL=${SMTP_FROM} |
| Mastodon | SMTP_SERVER=${SMTP_HOST}, SMTP_LOGIN=${SMTP_USER}, SMTP_FROM_ADDRESS=${SMTP_FROM} (SMTP_PORT/SMTP_PASSWORD already match) |
| Directus | EMAIL_TRANSPORT="smtp", EMAIL_SMTP_HOST=${SMTP_HOST}, _PORT, _USER=${SMTP_USER}, _PASSWORD, EMAIL_FROM=${SMTP_FROM}, EMAIL_SMTP_SECURE="false" |
| Formbricks | core SMTP_HOST/PORT/USER/PASSWORD direct; add MAIL_FROM=${SMTP_FROM}, SMTP_SECURE_ENABLED="0" |
| Vaultwarden | SMTP_HOST/PORT/FROM direct; add SMTP_USERNAME=${SMTP_USER}, SMTP_SECURITY="starttls" |
| Grafana | GF_SMTP_ENABLED="true", GF_SMTP_USER=${SMTP_USER}, GF_SMTP_PASSWORD, GF_SMTP_FROM_ADDRESS=${SMTP_FROM}, and [env.computed] GF_SMTP_HOST="${SMTP_HOST}:${SMTP_PORT}" |
| GlitchTip | EMAIL_URL="smtp://${SMTP_USER}:${SMTP_PASSWORD}@${SMTP_HOST}:${SMTP_PORT}" (URL-encode user/pass if they contain @ : /) |
A few apps ship with their mailer toggled off in Hop3's generated config (Forgejo/Gitea
[mailer] ENABLED=false, VikunjaVIKUNJA_MAILER_ENABLED=false); the remap above only takes effect once that default is cleared.
Not yet reachable by env: apps that read SMTP from a config file or DB — Nextcloud, Matrix-Synapse, MediaWiki, Keycloak, Wiki.js, Redmine, Dolibarr, Kanboard, LimeSurvey, Ghost — can't be wired by [env] today. They need a per-app config-file/post-deploy step; see the internal catalog-fit note.
Common Patterns¶
Mapping to app-specific variable names¶
If your app expects variables like DB_HOST or CACHE_URL rather than the canonical Hop3 names, remap in [env]:
[env]
DB_HOST = "${PGHOST}"
DB_PORT = "${PGPORT}"
DB_NAME = "${PGDATABASE}"
DB_USER = "${PGUSER}"
DB_PASS = "${PGPASSWORD}"
CACHE_URL = "${REDIS_URL}"
Or — for apps that translate only at runtime (e.g. Django reading POSTGRES_USER) — wrap the start command:
[run]
start = "env POSTGRES_USER=$PGUSER POSTGRES_PASSWORD=$PGPASSWORD POSTGRES_DB=$PGDATABASE POSTGRES_HOST=$PGHOST POSTGRES_PORT=$PGPORT gunicorn myapp.wsgi:application --bind 0.0.0.0:$PORT"
Sharing an addon across apps¶
hop3 addon create postgres shared-db
hop3 addon attach shared-db --app api
hop3 addon attach shared-db --app worker
hop3 addon attach shared-db --app admin
Each app receives the same DATABASE_URL and can coordinate via the shared database.
Multiple addons of the same type¶
Declare one [[addons]] block per instance:
When several addons of the same type are attached to one app, one is the primary and injects the canonical, unprefixed variables (REDIS_URL, etc.); each additional instance injects the same keys prefixed with its uppercased name (e.g. JOB_QUEUE_REDIS_URL). A single attached addon is always primary. Use hop3 addon promote <name> --app <app> to choose which instance is primary.
Backup and Restore¶
Addons are included in app-level backups by default. See Backup & Restore.
hop3 backup create --app my-app # Includes attached addon data
hop3 backup create --app my-app --no-addons # App code + env only
For a single addon, hop3 addon <type> dump <name> writes a standalone dump to the server's backup area, and hop3 addon <type> restore <name> <path> restores it. All four types support both verbs.
See Also¶
- hop3.toml reference:
[[addons]]— full schema - Backup & Restore
- Troubleshooting