docker compose psshows service state.docker compose logs -f <service>streams logs.docker compose restart <service>restarts one service.docker compose downstops the stack.
Use:
cp .env.example .env
cp services/glitchtip.env.example services/glitchtip.env
cp services/plane.env.example services/plane.env
cp services/n8n.env.example services/n8n.env
./scripts/generate-secrets.sh --apply
./scripts/update.shThat pulls refreshed images and recreates the stack without touching the persistent volumes.
- PostgreSQL should stay healthy and keep three application databases.
- Redis should remain on a persistent volume.
- MinIO should keep the Plane and GlitchTip buckets.
- Caddy should hold the only public ports on the host.
- GlitchTip retention is configured in
services/glitchtip.envwith a 30-day default. - n8n execution retention is pruned with
EXECUTIONS_DATA_PRUNE=trueand a short history window. - Plane relies on its own upstream storage model plus the shared Postgres and MinIO services.
- Confirm the public domains return HTTPS.
- Verify GlitchTip ingestion works by sending a test error from a sample app.
- Verify n8n webhooks still work even though the UI is protected by Caddy basic auth.
- Restart the stack and confirm the data volumes survive.
- Send a test invite from GlitchTip and Plane, then confirm delivery in the target inbox.
- If Plane fails to boot, check the RabbitMQ and MinIO services first.
- If GlitchTip fails, confirm
DATABASE_URL,SECRET_KEY, andGLITCHTIP_DOMAIN. - If n8n loops on startup, confirm
N8N_ENCRYPTION_KEYand the PostgreSQL credentials.
Run these when invites or password resets are not being delivered:
docker compose exec -T plane sh -c 'tail -50 /app/logs/error/worker.err.log' | grep -i "email\|smtp\|530\|sent"
docker compose exec -T glitchtip env | grep '^EMAIL_URL='Plane stores SMTP config in its InstanceConfiguration database table — not
in services/plane.env. The env vars in plane.env are only used as fallback
defaults when the DB rows are empty. The Celery worker reads configuration via
get_email_configuration() which queries the DB first.
Check what the DB currently has:
docker compose exec -T plane sh -c 'cd /app/backend && python3 -c "
import os, django
os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"plane.settings.production\"
django.setup()
from plane.license.utils.instance_value import get_email_configuration
print(get_email_configuration())
"'If HOST, USER, PASSWORD, or FROM are empty, configure them:
docker compose exec -T plane sh -c 'cd /app/backend && python3 -c "
import os, django
os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"plane.settings.production\"
django.setup()
from plane.license.models import InstanceConfiguration
from plane.license.utils.encryption import encrypt_data
updates = {
\"EMAIL_HOST\": \"smtp.resend.com\",
\"EMAIL_HOST_USER\": \"resend\",
\"EMAIL_HOST_PASSWORD\": encrypt_data(\"<resend-api-key>\"),
\"EMAIL_PORT\": \"587\",
\"EMAIL_USE_TLS\": \"1\",
\"EMAIL_USE_SSL\": \"0\",
\"EMAIL_FROM\": \"plane@internal.bobadilla.tech\",
}
for key, val in updates.items():
n = InstanceConfiguration.objects.filter(key=key).update(value=val)
if not n:
InstanceConfiguration.objects.create(key=key, value=val)
print(key, \"ok\")
"'Important notes:
EMAIL_HOST_PASSWORDmust be stored encrypted usingencrypt_data(). Storing plain text will causeInvalidTokenerrors at send time.EMAIL_USE_TLSmust be"1"(not"true"). The task does a literal== "1"check.- The sending domain (
internal.bobadilla.tech) must be verified in the Resend dashboard before emails will go through. DNS propagation can take a few hours.
GlitchTip uses EMAIL_URL in services/glitchtip.env:
EMAIL_URL=smtp://resend:<resend_api_key>@smtp.resend.com:587/?tls=True
DEFAULT_FROM_EMAIL=glitchtip@internal.bobadilla.tech
If GlitchTip still uses consolemail://, invitation links are written to logs.
Extract and clean wrapped links with:
docker compose logs --tail=500 glitchtip \
| perl -pe 's/=\n//g; s/=3D/=/g' \
| grep -o 'https://issues\.bobadilla\.tech/(accept|profile/confirm-email)/[^" ]+'When app services fail due to DB auth drift, startup order, or n8n encryption key mismatch, run:
./scripts/recover.shThis script:
- starts core dependencies (
postgres,redis,rabbitmq,minio), - waits for PostgreSQL readiness,
- reconciles app roles/databases against
.envpasswords, - resets only n8n local state to avoid key mismatch loops,
- recreates
n8n,glitchtip, andcaddy, then startsplane.
If Plane is already working and the upstream image tag is broken, snapshot the
live container with docker export | docker import instead of docker commit.
docker commit inherits the image's JSON-array VOLUME declaration in a form
Docker can't parse, causing:
invalid mount config for type "volume": invalid mount path: '[/app/data,'
Use this instead:
# Capture the entrypoint from the running container
CMD=$(docker inspect internal-tools-plane-1 --format='{{json .Config.Cmd}}')
# Export filesystem and reimport clean (no VOLUME metadata)
docker export internal-tools-plane-1 | docker import \
--change "CMD $CMD" \
- plane-aio-community:working
# Point .env at the new local image
sed -i 's#^PLANE_IMAGE=.*#PLANE_IMAGE=plane-aio-community:working#' .env
docker compose up -d --force-recreate planeUse this only to reload Plane env changes without changing Plane's application version.
MinIO stores its root credentials in the data volume on first init. The
MINIO_ROOT_PASSWORD in .env must match whatever is in the volume — they can
drift if the stack was initialized with a different password.
To find the actual password:
docker compose exec -T minio env | grep MINIO_ROOT_PASSWORDThen sync .env:
sed -i "s#^MINIO_ROOT_PASSWORD=.*#MINIO_ROOT_PASSWORD=<actual_password>#" .envIf Plane reports image upload errors or the uploads bucket is missing, run
minio-init manually (it is idempotent):
docker compose up --force-recreate minio-initThis creates the uploads and glitchtip-files buckets, sets public-download
policy, and creates the plane-access user with readwrite permissions. If
minio-init still fails, check that MINIO_ROOT_PASSWORD in .env matches the
volume (see above).
Plane's start.sh hardcodes USE_MINIO=0 in its own env file, which causes the
storage backend to generate presigned upload URLs pointing to
http://minio:9000 — an internal address the browser can never reach.
The fix has two parts (both already wired in):
-
scripts/patch-plane-settings.pyruns at container startup and comments out theUSE_MINIO=0line instart.sh, keepingUSE_MINIO=1from the container environment. WithUSE_MINIO=1, the storage backend generates presigned POST URLs usingrequest.get_host()— i.e.https://tasks.bobadilla.tech/uploads. -
Caddyfilerouteshandle /uploads*ontasks.bobadilla.techdirectly tominio:9000, so the browser's upload POST reaches MinIO through the same public domain.
If image uploads break again after a Plane image update, check:
# Confirm USE_MINIO=1 in the gunicorn process
docker compose exec -T plane sh -c 'cat /proc/$(pgrep -f gunicorn | head -1)/environ | tr "\0" "\n" | grep USE_MINIO'
# Confirm the patch ran
docker compose exec -T plane sh -c 'grep "patched" /app/start.sh'
# Confirm Caddy routes /uploads to MinIO (should return XML, not Plane HTML)
curl -s https://tasks.bobadilla.tech/uploads | head -3If USE_MINIO is 0, force-recreate the plane container (the patch runs at startup):
docker compose up -d --force-recreate planeGod Mode (/god-mode/) uses a separate InstanceAdmin model — not the regular
is_superuser / is_staff flags. If login fails with "Authentication failed",
add the user as a verified instance admin:
docker compose exec -T plane sh -c 'cd /app/backend && python3 -c "
import os,django
os.environ[\"DJANGO_SETTINGS_MODULE\"]=\"plane.settings.production\"
django.setup()
from plane.license.models import InstanceAdmin, Instance
from plane.db.models import User
u = User.objects.get(email=\"eliaz@bobadilla.tech\")
inst = Instance.objects.first()
InstanceAdmin.objects.update_or_create(user=u, instance=inst, defaults={\"role\":20,\"is_verified\":True})
print(\"done\")
"'Then log in at https://tasks.bobadilla.tech/god-mode/ with the regular Plane
account password.
Mattermost runs at https://chat.bobadilla.tech (port 8065, proxied by Caddy).
It uses the shared PostgreSQL (mattermost_db) and a local Docker volume for
file storage.
On a fresh deploy, visit https://chat.bobadilla.tech and complete the setup
wizard to create the admin account. No post-deploy DB commands needed.
Docker init scripts only run on an empty volume. If postgres_data already
exists, create the database manually before starting Mattermost. Run each
statement as a separate -c call — CREATE DATABASE cannot run inside a
transaction block:
MATTERMOST_DB_PASSWORD=$(grep ^MATTERMOST_DB_PASSWORD .env | cut -d= -f2)
docker compose exec -T postgres psql -U postgres_admin -d postgres \
-c "CREATE ROLE mattermost_user LOGIN PASSWORD '$MATTERMOST_DB_PASSWORD';"
docker compose exec -T postgres psql -U postgres_admin -d postgres \
-c "CREATE DATABASE mattermost_db OWNER mattermost_user;"
docker compose exec -T postgres psql -U postgres_admin -d postgres \
-c "GRANT ALL PRIVILEGES ON DATABASE mattermost_db TO mattermost_user;"
docker compose up -d mattermostIf Mattermost fails with password authentication failed, the password in
.env may contain a trailing = (base64 padding) which breaks the PostgreSQL
URL. Reset it:
NEW_PW=$(openssl rand -base64 32 | tr -d '\n=' | tr '/+' 'ab' | cut -c1-48)
sed -i "s#^MATTERMOST_DB_PASSWORD=.*#MATTERMOST_DB_PASSWORD=$NEW_PW#" .env
docker compose exec -T postgres psql -U postgres_admin -d postgres \
-c "ALTER ROLE mattermost_user PASSWORD '$NEW_PW';"
docker compose up -d mattermostThe generate-secrets.sh script has been fixed to never produce = in
passwords.
To rename the default workspace slug (e.g. agency-core → core):
docker compose exec -T postgres psql -U plane_user -d plane_db -c \
"UPDATE workspaces SET slug='core', name='Core' WHERE slug='agency-core' RETURNING id, name, slug;"After a fresh deploy (new server or wiped volumes), complete these steps once:
-
Verify the sending domain in Resend:
internal.bobadilla.techmust show status "Domain verified" before any email will go through. -
Configure Plane email in the DB (see SMTP section above). This is required on every fresh Postgres volume because the InstanceConfiguration table starts empty and env-var fallbacks are not reliable.
-
Verify MinIO buckets and user exist: run
docker compose up --force-recreate minio-initif Plane reports image upload errors on first use. This creates theuploadsbucket andplane-accessMinIO user. -
Mattermost first-run: visit
https://chat.bobadilla.techand complete the setup wizard to create the admin account.