How to Package Applications for Hop3¶
This guide provides step-by-step instructions for packaging your applications so they can be deployed and managed by Hop3. Whether you're deploying a Python web app, a Node.js service, a static site, or any other type of application, this guide will show you exactly what files to create and how to structure your project.
Table of Contents¶
- Understanding Hop3 Application Structure
- Quick Start: Minimal Configuration
- Configuration Options
- Complete Examples by Technology
- Advanced Patterns
- Testing Your Package
- Troubleshooting
Understanding Hop3 Application Structure¶
Hop3 applications require minimal configuration to deploy. At its core, you need:
- Your application code: the actual source files.
- Dependency declaration: how to install required packages.
- Process definition: how to run your application.
Hop3 supports two configuration approaches:
- Procfile (convention): simple, Heroku-compatible, works out of the box.
- hop3.toml (configuration): advanced, full-featured, optional.
You can use either one or both together. When both exist, hop3.toml settings override Procfile settings.
Quick Start: Minimal Configuration¶
Option 1: Using Procfile Only (Simplest)¶
For most applications, a simple Procfile is all you need.
Project structure:
my-app/
├── app.py # Your application code
├── requirements.txt # Python dependencies
└── Procfile # Process definition
Procfile:
That's it! Hop3 will:
- Detect it's a Python application (from requirements.txt)
- Install dependencies automatically
- Run the command specified in Procfile
Option 2: Using hop3.toml Only (Full Control)¶
For more control, use hop3.toml:
Project structure:
hop3.toml:
Option 3: Hybrid Approach (Best of Both)¶
Use Procfile for basics and hop3.toml for advanced features:
Procfile:
hop3.toml:
[metadata]
id = "my-app"
[build]
before-build = "pip install -r requirements-dev.txt"
[[addons]]
type = "postgres"
Result: the web and worker processes come from the Procfile, and the build configuration and database come from hop3.toml.
Configuration Options¶
Procfile Format¶
The Procfile declares process types using the format:
Common process types:
| Process Type | Purpose | Example |
|---|---|---|
web |
HTTP server (public-facing) | web: gunicorn app:app |
worker |
Background job processor | worker: celery worker |
scheduler |
Scheduled tasks | scheduler: celery beat |
static |
Static file directory | static: public |
wsgi |
WSGI application | wsgi: app:application |
Special commands:
| Command | When It Runs | Purpose |
|---|---|---|
prebuild |
Before building | Install build tools, compile assets |
build |
During build phase | Build your application |
prerun |
Before starting app | Database migrations, setup tasks |
Example comprehensive Procfile:
prebuild: npm ci
build: npm run build
prerun: python manage.py migrate
web: gunicorn myapp.wsgi:application --workers 4
worker: celery worker --app=myapp
scheduler: celery beat --app=myapp
static: public
hop3.toml Sections¶
[metadata] - Application Identity¶
[metadata]
id = "my-app" # Required: Unique identifier
version = "1.0.0" # Semantic version
title = "My Application" # Human-readable name
author = "Your Name <you@example.com>"
description = "A brief description"
[build] - Build Process¶
[build]
# Single command
before-build = "npm install"
# Multiple commands (executed with &&)
before-build = ["pip install build-tools", "make setup"]
# Build commands
build = "npm run build"
# System packages needed for build
packages = ["nodejs", "gcc", "make"]
# Python packages for build
pip-install = ["setuptools", "wheel"]
# Run tests after build
test = "npm test"
Downloading upstream sources: use hop3-fetch, not curl¶
A recipe that fetches an upstream tarball in before-build should call hop3-fetch, and not curl or wget:
hop3-fetch "$URL" | tar xz --strip-components=1 # pipe into tar
hop3-fetch "$URL" -o app.tar.gz --sha256 "$SHA256" # or write a file
It downloads through a cache shared by every app on the server, so redeploying the same pinned version does not ask upstream again. The benefit goes beyond speed: a catalog run pulls dozens of tags from the same few hosts within minutes, and GitHub's codeload rate-limits the address. After that, curl gets HTTP 429 and the deploy fails with nothing wrong in the recipe. Retrying harder makes it worse; not asking twice fixes it.
hop3-fetch also fails loudly on an HTTP error instead of saving the error page as the artifact, retries transient failures with backoff (honouring Retry-After), and verifies --sha256 before admitting the download to the cache, so a cache hit is proof the bytes are the ones the recipe pinned.
It also checks the body against the Content-Length the server promised. A connection that drops mid-transfer leaves a short file and raises nothing. Without that check, the fetch reports a download that did not happen, and an unpinned recipe, having nothing else to compare against, caches the fragment and serves it to every later build. Pin --sha256 wherever upstream publishes one: it is the only thing that can detect a body which arrived complete but wrong.
--refresh discards any cached copy and downloads again. Use it when a cache entry is suspect; otherwise a cache hit is returned without being read.
When piping into tar, set set -eo pipefail in the script. Without it the pipeline's exit status is tar's, so a failed download surfaces as a misleading "Unexpected EOF" instead of the actual error.
[run] - Runtime Configuration¶
[run]
# Main application command (maps to Procfile "web:")
start = "gunicorn app:app --workers 4"
# Commands before starting (maps to Procfile "prerun:")
before-run = ["python manage.py migrate", "python manage.py collectstatic"]
# System packages needed at runtime
packages = ["postgresql-client", "redis"]
[env] - Environment Variables¶
[env]
DEBUG = "false"
DATABASE_URL = "postgresql://localhost/mydb"
LOG_LEVEL = "info"
ALLOWED_HOSTS = "myapp.example.com"
⚠️ Security Note: Don't hardcode secrets in hop3.toml. There are two cases:
- App-internal random secrets (
SECRET_KEY,SECRET_KEY_BASE,APP_KEY, …): values the app only needs to be random and unguessable. Declare them and Hop3 generates one on the first deploy, persists it, and never rotates or commits it:
See Generated secrets for the available generators.
- Externally-supplied secrets (API tokens, third-party passwords): specific values you provide. Set these with the CLI, never in
hop3.toml:
[port] - Port Configuration¶
[port]
web = 8000 # Main web service port
api = 8080 # API service port
metrics = 9090 # Metrics endpoint
[healthcheck] - Health Monitoring¶
[healthcheck]
path = "/health/" # Health check endpoint
timeout = 30 # Request timeout (seconds)
interval = 60 # Check frequency (seconds)
[[addons]] - Service Dependencies¶
Use double brackets [[addons]] for arrays in TOML. Each entry declares a backing service (PostgreSQL, MySQL, Redis, or S3/MinIO) whose connection details are injected into the app's environment.
([[provider]] with a name key is the deprecated spelling of the same section; new configs should use [[addons]] with type.)
[[volumes]] - Where Your App Writes¶
Every deploy replaces the app's source tree: src/ is deleted and extracted again from the new version. Anything the running app writes inside it is lost on the next deploy, including an upgrade. So every directory the app writes to at runtime must be declared:
# Uploads, a SQLite database, configuration the app writes itself: kept
[[volumes]]
name = "storage"
target = "storage/app"
# A cache a new version must not inherit: reset on every deploy
[[volumes]]
name = "cache"
target = "bootstrap/cache"
type = "scratch"
Frameworks write in places that are easy to miss: Laravel's storage/ and bootstrap/cache/, WordPress's wp-content/uploads/, a Django MEDIA_ROOT, any relative data/ path an app resolves against its working directory (which is src/). hop3-test fails an app that writes into src/ after it starts and lists the directories, so the test shows what to declare. See the [[volumes]] reference for seeding and the other fields.
Complete Examples by Technology¶
Python: Flask with pip¶
Files needed:
app.py:
from flask import Flask
import os
app = Flask(__name__)
@app.route('/')
def hello():
return 'Hello from Flask!'
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
app.run(host='0.0.0.0', port=port)
requirements.txt:
Procfile:
Deploy:
Python: Django with Poetry¶
Files needed:
django-app/
├── myproject/
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── manage.py
├── pyproject.toml
└── hop3.toml
pyproject.toml:
[tool.poetry]
name = "django-app"
version = "1.0.0"
[tool.poetry.dependencies]
python = "^3.10"
Django = "^4.2"
gunicorn = "^21.0"
psycopg2-binary = "^2.9"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
hop3.toml:
[metadata]
id = "django-app"
version = "1.0.0"
[build]
# Poetry automatically installs dependencies
before-build = "poetry install --no-dev"
[run]
start = "poetry run gunicorn myproject.wsgi:application --workers 4"
before-run = "poetry run python manage.py migrate --noinput"
[env]
DJANGO_SETTINGS_MODULE = "myproject.settings"
SECRET_KEY = { generate = "hex", length = 32 }
[[addons]]
type = "postgres"
Deploy:
cd django-app
git init
git add .
git commit -m "Initial commit"
# SECRET_KEY is declared as { generate = "hex", length = 32 } in hop3.toml [env],
# so it's created automatically on the first deploy — no manual step.
hop3 deploy
Node.js: Express Application¶
Files needed:
package.json:
{
"name": "express-app",
"version": "1.0.0",
"main": "app.js",
"dependencies": {
"express": "^4.18.0"
},
"scripts": {
"start": "node app.js"
}
}
app.js:
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
const host = process.env.BIND_ADDRESS || '0.0.0.0';
app.get('/', (req, res) => {
res.send('Hello from Express!');
});
app.listen(port, host, () => {
console.log(`Server running on ${host}:${port}`);
});
Procfile:
Deploy:
cd express-app
npm install # Generate package-lock.json
git init
git add .
git commit -m "Initial commit"
hop3 deploy
Node.js: TypeScript Application¶
Files needed:
package.json:
{
"name": "typescript-app",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.18.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true
}
}
hop3.toml:
[metadata]
id = "typescript-app"
[build]
before-build = "npm ci"
build = "npm run build"
test = "npm test"
[run]
start = "npm start"
Static Website¶
Files needed:
public/index.html:
<!DOCTYPE html>
<html>
<head>
<title>My Static Site</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Hello from Hop3!</h1>
<script src="script.js"></script>
</body>
</html>
Procfile:
Deploy:
PHP Application¶
PHP is the one language with no start command. Hop3 runs a php-fpm pool and points nginx at it over FastCGI: nginx serves static files off disk, hands .php requests to the pool, and falls back to the front controller so framework routing works unconfigured.
Files needed:
hop3.toml:
[metadata]
id = "my-php-app"
version = "1.0.0"
[build]
toolchain = "php"
packages = ["php", "php-pgsql", "php-mbstring", "composer"]
before-build = "composer install --no-dev --optimize-autoloader"
[run]
before-run = "php bin/console doctrine:migrations:migrate --no-interaction"
[[addons]]
type = "postgres"
[healthcheck]
path = "/"
The document root. Hop3 serves public/, web/, htdocs/ or www/ when one of them holds an index.php, and the project root otherwise. Declare [build].document-root = "…" when your web root is elsewhere. This matters more than it looks: serving the project root publishes your .env, your composer.json and your whole vendor/ tree, so only the resolved document root is made readable by the web server and a document-root that is not a directory is refused at build time.
Data inside the document root. The proxy never serves hidden files there (.env, .htaccess, .git/). Data an app writes under its web root (a SQLite file, compiled templates, uploads) is protected upstream by an .htaccess that only Apache reads, so list it in [proxy] deny = ["/data"] and the proxy answers it with 404.
If you want a different runtime — Laravel Octane, FrankenPHP, RoadRunner — declare it as [run].start and it replaces the pool. Do not reach for php -S or php artisan serve: they are development servers, single-threaded, and an app that makes an HTTP request to itself (Nextcloud and Matomo both do) deadlocks against its own only worker.
Go Application¶
Files needed:
go.mod:
server.go:
package main
import (
"fmt"
"os"
"github.com/gin-gonic/gin"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.String(200, "Hello from Go!")
})
r.Run(":" + port)
}
Procfile:
hop3.toml:
Advanced Patterns¶
Multi-Process Applications¶
Procfile:
# Web server
web: gunicorn app:app --workers 4
# Background worker
worker: celery worker --app=myapp --loglevel=info
# Scheduler
scheduler: celery beat --app=myapp --loglevel=info
# Real-time service
realtime: python websocket_server.py
Each process type runs independently and scales separately.
Build-Time vs Runtime Packages¶
Some packages are only needed during build, others at runtime:
hop3.toml:
[build]
# Build-time packages (compilers, build tools)
packages = ["gcc", "g++", "make", "nodejs"]
pip-install = ["setuptools", "wheel", "cython"]
# Build step
build = "python setup.py build_ext"
[run]
# Runtime packages (minimal set for production)
packages = ["postgresql-client"]
# No build tools needed at runtime
start = "gunicorn app:app"
Benefits: - Smaller runtime container - Faster deployments - Better security (fewer packages = smaller attack surface)
Environment-Specific Configuration¶
Local .env file (not committed):
Production (via CLI):
hop3 env set --app myapp DEBUG=false
hop3 env set --app myapp DATABASE_URL="postgresql://devel-server/devel_db"
# SECRET_KEY: declare `{ generate = "hex", length = 32 }` in hop3.toml [env]
# instead — generated once and persisted, never typed or committed.
hop3.toml (defaults only):
Database Migrations¶
Option 1: Before Run Hook (Recommended)
hop3.toml:
Migrations run automatically before each deployment.
Option 2: One-off Command
Run the migration as a one-off command against the deployed app, then deploy:
hop3 app run --app myapp python manage.py migrate # Run migrations
hop3 deploy --app myapp # Deploy new version
hop3 app run executes the given command line inside the app's environment (the same virtualenv and env vars the app runs with).
Asset Compilation¶
hop3.toml:
[build]
before-build = "npm ci"
build = ["npm run build", "python manage.py collectstatic --noinput"]
[run]
start = "gunicorn app:app"
What happens:
1. Install npm dependencies (npm ci)
2. Build frontend assets (npm run build)
3. Collect Django static files (collectstatic)
4. Start application
Multi-Stage Builds¶
For Node.js applications:
hop3.toml:
[build]
# Install all dependencies (including dev)
before-build = "npm ci"
# Build production assets
build = "npm run build"
# Prune dev dependencies before deployment
build = ["npm run build", "npm prune --production"]
[run]
start = "node dist/server.js"
Custom Build Scripts¶
package.json:
{
"scripts": {
"build": "tsc && webpack --mode production",
"postbuild": "node scripts/optimize.js"
}
}
hop3.toml:
[build]
before-build = "npm ci"
build = "npm run build"
# npm automatically runs postbuild after build
Wiring config from injected credentials¶
When you attach an addon, Hop3 injects its connection details as environment variables (DATABASE_URL, REDIS_URL, SMTP_HOST, …). An app that reads those from the environment is configured the moment it's attached, with no further work. Some apps instead read their settings from a config file (homeserver.yaml, LocalSettings.php, an INI section) or from their own database (set through a CLI like occ). Environment injection cannot reach those, so render the config from the injected environment in a before-run command; it runs on every deploy with every injected variable present:
# scripts/render-config.sh
set -euo pipefail
# Fail loud: the injected SMTP_HOST must be present (addon attached) before we render.
: "${SMTP_HOST:?attach an email addon — this app cannot send mail without one}"
# Render the app's own config file from the injected values.
cat > config/mail.yaml <<EOF
smtp_host: ${SMTP_HOST}
smtp_port: ${SMTP_PORT:-587}
smtp_user: ${SMTP_USER}
smtp_pass: ${SMTP_PASSWORD}
EOF
chmod 600 config/mail.yaml
Four conventions keep these scripts correct; each targets a real failure seen while packaging the catalog:
Never hardcode a feature off. A generated config that pins MAIL_MAILER=log or MAILER_ENABLED=false shadows the injected value and silently disables the feature. Default through the injected value so an attached addon (or an [env] remap) wins:
MAIL_MAILER=log # bad — drops all mail even with an addon attached
MAIL_MAILER=${MAIL_MAILER:-log} # good — log unless something set it
Keep secrets out of argv. Interpolating a secret into a command line leaks it to the process list (ps, /proc/<pid>/cmdline) for every local user. Prefer a CLI's environment or stdin channel where it has one (Nextcloud's occ reads the admin password from OC_PASS); fall back to --value=$SECRET only when the tool offers nothing else:
myapp-cli set-smtp-password --value="$SMTP_PASSWORD" # leaks to `ps`
printf '%s' "$SMTP_PASSWORD" | myapp-cli set-smtp-password --stdin # better
Fail loud. Start with set -euo pipefail and assert required variables (: "${SMTP_HOST:?…}"), so a missing credential aborts the deploy instead of writing a half-rendered config with a literal ${SMTP_PASSWORD} left in it.
Make setup commands idempotent. A before-run step runs on every redeploy. Guard a non-idempotent command (an installer, an import) with a probe so it doesn't double-apply:
A future Hop3 release may add a declarative form of this:
[[config-files]]to render a file and[[setup]]to run a guarded command, applying these rules by construction (see ADR 051). Until then, abefore-runscript held to the four conventions is the supported way to wire a config-file or DB app.
Testing Your Package¶
1. Test Locally First¶
Create local .env file:
Run your start command:
# Python example
source .env
gunicorn app:app --bind 0.0.0.0:$PORT
# Node.js example
source .env
node app.js
Test in browser:
2. Validate Configuration Files¶
Check Procfile syntax:
# Each line should be: <process-type>: <command>
cat Procfile
# Good:
web: gunicorn app:app
# Bad (missing colon):
web gunicorn app:app
Validate hop3.toml:
3. Test Deployment in Staging¶
Create a test deployment:
# Deploy to staging first
hop3 deploy --app myapp-staging
# Test the deployed app
curl https://myapp-staging.hop3.example.com
# Check logs
hop3 app logs --app myapp-staging
# Check status
hop3 app status --app myapp-staging
4. Common Issues to Check¶
Environment variables:
# Verify all required env vars are set
hop3 env show --app myapp
# Add missing vars
hop3 env set --app myapp DATABASE_URL="..."
Port binding:
# Your app MUST use the PORT environment variable
# Hop3 sets this automatically
# Good (Python):
port = int(os.environ.get('PORT', 5000))
# Good (Node.js):
const port = process.env.PORT || 3000
# Bad (hardcoded):
app.listen(8000) # Won't work!
File paths:
# Use relative paths, not absolute
# Good:
./manage.py migrate
# Bad:
/home/user/myapp/manage.py migrate
Troubleshooting¶
Application Won't Start¶
Check logs:
Common causes:
-
Wrong start command
-
Missing dependencies
-
Port binding issues
Build Failures¶
Check build logs:
Common causes:
-
Missing build dependencies
-
Build timeout
Database Connection Errors¶
Check DATABASE_URL:
Verify database service:
# List attached services
hop3 addon list --app myapp
# Attach database if missing
hop3 addon create postgres myapp-db
hop3 addon attach myapp-db --app myapp
Static Files Not Serving¶
Check static configuration:
Verify directory exists:
Permission Errors¶
Don't use sudo in commands:
Environment Variable Not Available¶
Set via CLI:
# Set the variable
hop3 env set --app myapp MY_VAR="value"
# Restart to apply
hop3 app restart --app myapp
# Verify
hop3 env show --app myapp
Best Practices Checklist¶
- Use
$PORTenvironment variable for port binding - Never hardcode secrets in
hop3.tomlorProcfile - Include all dependencies in
requirements.txt/package.json - Test locally before deploying
- Use
before-runfor database migrations - Set up health check endpoints
- Configure automated backups for production
- Use environment variables for configuration
- Keep build artifacts out of git (use
.gitignore) - Document required environment variables in README
Quick Reference¶
Minimal Flask App:
Minimal Node.js App:
Static Site:
With Database:
With Build Step:
Next Steps¶
- Quickstart Guide - Deploy your first app
- hop3.toml Reference - Complete configuration reference
- Migration Guide - Migrate from other platforms
- CLI Reference - All available commands
For help, run: