Pav Staz
03/13/2026, 11:22 AMMarvin
03/13/2026, 11:23 AMMarvin
03/13/2026, 11:25 AMfrom prefect import flow
from prefect_dbt import PrefectDbtRunner, PrefectDbtSettings
@flow
def run_dbt():
runner = PrefectDbtRunner(
settings=PrefectDbtSettings(
project_dir="/path/to/your/dbt/project",
profiles_dir="/path/to/dir/that/contains/profiles.yml"
)
)
runner.invoke(["deps", "run", "test"]) # or any dbt CLI commands in order
if __name__ == "__main__":
run_dbt()
Passing your Snowflake env vars (SNOWFLAKE_ACCOUNT, SNOWFLAKE_USER, SNOWFLAKE_PRIVATE_KEY)
Since your profiles.yml uses env_var('...'), you just need those env vars present in the process that runs dbt. You’ve got a few good options:
- Set them on the worker/deployment environment
- Recommended for production. Put SNOWFLAKE_ACCOUNT, SNOWFLAKE_USER, SNOWFLAKE_PRIVATE_KEY in the environment where your Prefect worker runs (VM/container/Kubernetes). dbt will pick them up automatically.
- You can also set default environment variables on a Work Pool in Prefect Cloud/Server so every deployment/worker registered to that pool inherits them.
- Load them from Prefect Secret blocks in code and export to the environment before invoking dbt
import os
from prefect.blocks.system import Secret
from prefect_dbt import PrefectDbtRunner, PrefectDbtSettings
def export_snowflake_env_from_secrets():
os.environ["SNOWFLAKE_ACCOUNT"] = Secret.load("snowflake-account").get()
os.environ["SNOWFLAKE_USER"] = Secret.load("snowflake-user").get()
os.environ["SNOWFLAKE_PRIVATE_KEY"] = Secret.load("snowflake-private-key").get()
def build_runner():
return PrefectDbtRunner(
settings=PrefectDbtSettings(
project_dir="/path/to/your/dbt/project",
profiles_dir="/path/to/dir/that/contains/profiles.yml"
)
)
from prefect import flow
@flow
def run_dbt():
export_snowflake_env_from_secrets()
runner = build_runner()
runner.invoke(["run", "test"])
- Use DBT_* environment variables (optional)
- You can set DBT_PROFILES_DIR and DBT_PROJECT_DIR as env vars instead of passing them via settings if you prefer that style.
How this looks in the Prefect UI (errors, warnings, and per-model visibility)
- With the modern Prefect dbt runner, each dbt node (models, tests, seeds, snapshots) is reflected as its own Prefect task run. You’ll see something like “model my_model” and “test not_null_my_model_id” as separate tasks in the run graph.
- Logs from dbt are streamed into those specific tasks. If a model fails, that task shows as Failed with the dbt error details in its logs.
- The overall flow run will fail on dbt errors by default; you can change that by initializing the runner with raise_on_failure=False if you need the flow to continue.
Common gotchasMarvin
03/13/2026, 11:25 AMprofiles_dir to the runner.
- Ensure the environment that runs the flow actually has the env vars set (or sets them from Prefect Secrets) before runner.invoke(...) runs.
- If you previously used the legacy DbtCoreOperation/DbtCliTask, those typically produce a single Prefect task for the whole run. Switch to the modern runner to get per-model tasks as shown above.
If you share how you’re running (local, Docker, K8s) and where your profiles.yml lives, I can suggest the cleanest env/secret setup for your case.Pav Staz
03/13/2026, 5:56 PMpem_path = Path(__file__).parent / "snowflake.pem"
if not pem_path.exists():
raise FileNotFoundError(f"Snowflake PEM file not found at {pem_path}")
snowflake_private_key = pem_path.read_text()
secret = Secret(value=snowflake_private_key)
secret.save('snowflake-private-key', overwrite=True)
then I .encode() when passing to make snowflake connections
but when doing this dbt flow:
os._environ_["_SNOWFLAKE_ACCOUNT_"] = Secret._load_("snowflake-account")._get_()
os._environ_["_SNOWFLAKE_USER_"] = Secret._load_("snowflake-user")._get_()
os._environ_["_SNOWFLAKE_PRIVATE_KEY_"] = Secret._load_("snowflake-private-key")._get_()._encode_()
_run_dbt_commands_(
commands=[
"deps",
"run --target dev --select organisations"
],
_project_dir_=project_path
)
i get the error with .encode
But before I get an error saying 'invalid snowflake credentials'
the key is 100% correct, as I use it in other flows like this
private_key = serialization.load_pem_private_key(
_snowflake_private_key_._get_()._encode_(),
password=""._encode_() or None,
backend=_default_backend_()
)
snowflake_conn = snowflake.connector.connect(
user=_snowflake_user_._get_(),
account=_snowflake_account_._get_(),
_private_key_=private_key,
warehouse='DEF_WH',
role="DEVELOPER",
database="ANALYTICS",
schema="PROD_MARTS"
)Marvin
03/13/2026, 5:56 PMMarvin
03/13/2026, 5:58 PMos.environ[...] only accepts strings. Calling .encode() returns bytes, which raises this error.
- Fix: Set env vars as plain strings. Only encode when you pass the key to a library that expects bytes (e.g., serialization.load_pem_private_key), not when setting env.
2) dbt error: “Could not find adapter type snowflake!”
- Cause: The dbt Snowflake adapter is not installed in the environment where your flow is running.
- Fix: Ensure dbt-snowflake is installed alongside dbt-core (matching versions) in your flow’s runtime image or execution environment. For example:
- requirements.txt
prefect
prefect-dbt>=0.5.0
dbt-core~=1.7
dbt-snowflake~=1.7
- Or pip install in your image:
pip install "dbt-core~=1.7" "dbt-snowflake~=1.7" "prefect-dbt>=0.5.0"
Notes on the Snowflake private key for dbt
- dbt reads your profile YAML and passes credentials to the Snowflake adapter. For key pair auth you can use either:
- `private_key_path`: path to a PEM file
- `private_key`: the PEM contents as a string (multiline is okay if your environment variable preserves newlines)
- Since env vars can be finicky with newlines, a reliable pattern is to write the PEM to a temp file and point dbt at it via private_key_path.
Recommended changes
A) Set env vars as strings (no .encode()), and write the PEM to a temp file for dbt:
from prefect.blocks.system import Secret
import os, tempfile
# Set string env vars for dbt
os.environ["SNOWFLAKE_ACCOUNT"] = Secret.load("snowflake-account").get()
os.environ["SNOWFLAKE_USER"] = Secret.load("snowflake-user").get()
# Load the PEM as a string (no encode here)
pem_str = Secret.load("snowflake-private-key").get()
# Option 1 (recommended for dbt): write the key to a temp file and point dbt at it
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".pem") as f:
f.write(pem_str)
key_path = f.name
os.environ["SNOWFLAKE_PRIVATE_KEY_PATH"] = key_path
# If you also need to use the Snowflake Python connector directly elsewhere:
# convert to bytes only at the time of use
# private_key_bytes = pem_str.encode("utf-8")
# private_key = serialization.load_pem_private_key(private_key_bytes, password=None, backend=default_backend())
Then, in your dbt profile, prefer `private_key_path`:
dev:
type: snowflake
account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
user: "{{ env_var('SNOWFLAKE_USER') }}"
private_key_path: "{{ env_var('SNOWFLAKE_PRIVATE_KEY_PATH') }}"
warehouse: CWC_WH
role: DEVELOPER
database: DEV
schema: "{{ env_var('SNOWFLAKE_USER') }}"
threads: 1
client_session_keep_alive: False
B) If you’d rather keep private_key in the profile:
- Set os.environ["SNOWFLAKE_PRIVATE_KEY"] = pem_str (no .encode()).
- Keep private_key: "{{ env_var('SNOWFLAKE_PRIVATE_KEY') }}" in the profile.
- Be sure your env preserves the newlines in the key. If you see parsing/auth issues, switch to the private_key_path approach above.
C) Ensure the dbt Snowflake adapter is available at runtime:
- Inside your flow/image, run a quick check:
dbt --version
dbt debug --profile dev --target dev
If the adapter is missing, you’ll see the same “Could not find adapter type snowflake!” message.
Useful docs
- dbt Snowflake profile config: dbt docs: Snowflake profile
- Key pair auth in Snowflake: Snowflake docs: Key pair authentication
- Prefect dbt integration: prefect-dbt docs
A couple of quick confirmations to help me tailor guidance:
- Which image/environment is your flow running in (and does it include dbt-snowflake)?Marvin
03/13/2026, 5:58 PMprefect-dbt (e.g., DbtCoreOperation) or your own subprocess wrapper?
- Do you want to keep the PEM in an env var, or is writing a temp file acceptable for the dbt step?
Once the adapter is installed and the env vars are set as strings (or the key path is provided), both errors should be resolved.Pav Staz
03/18/2026, 1:23 PMMarvin
03/18/2026, 1:23 PMMarvin
03/18/2026, 1:28 PMfrom prefect_dbt.cli.configs import GlobalConfigs
from prefect_dbt.cli.credentials import DbtCliProfile
global_configs = GlobalConfigs(warn_error=True)
dbt_cli_profile = DbtCliProfile(
name="my_profile",
target="dev",
target_configs=..., # your target configs (e.g., Snowflake/BigQuery/Postgres)
global_configs=global_configs
)
This passes --warn-error to dbt, turning warnings into errors so Prefect will mark those tasks Failed.
Helpful refs:
- Prefect dbt collection docs: prefect-dbt docs
- dbt warn-as-error flag: dbt --warn-error
- dbt source freshness behavior: dbt source freshness
If you’re using the per-node orchestrator (since you mentioned each model/test is its own task), the above behavior applies per node. If you want, I can share a small example of enabling warn_error with the orchestrator pattern you’re using—just let me know which entrypoint you call today.Pav Staz
03/18/2026, 5:40 PMMarvin
03/18/2026, 5:40 PMMarvin
03/18/2026, 5:47 PMfrom uuid import UUID
from prefect import flow, serve
from prefect.events import DeploymentEventTrigger
@flow
def flow_a():
...
@flow
def flow_b():
...
@flow
def flow_c():
...
# A runs on a cron schedule
dep_a = flow_a.to_deployment(
name="dep-a",
cron="0 9 * * *", # 9 AM daily
)
# B runs when A completes
dep_b = flow_b.to_deployment(
name="dep-b",
triggers=[
DeploymentEventTrigger(
expect={"prefect.flow-run.Completed"},
match_related={"prefect.resource.name": "flow_a/dep-a"}, # <flow>/<deployment>
)
],
)
# C runs when B completes
dep_c = flow_c.to_deployment(
name="dep-c",
triggers=[
DeploymentEventTrigger(
expect={"prefect.flow-run.Completed"},
match_related={"prefect.resource.name": "flow_b/dep-b"},
)
],
)
if __name__ == "__main__":
serve(dep_a, dep_b, dep_c)
Option 2: Use the CLI to create automations from a YAML file
- Great for “infrastructure as code” and easy to run in a container init.
- First, get your deployment IDs:
prefect deployment ls --output json
- Then create a YAML with two automations (B after A completes, C after B completes):
automation_chain.yaml
- name: "Trigger B after A completes"
description: "Start dep-b after dep-a completes"
enabled: true
trigger:
type: event
posture: Reactive
expect: ["prefect.flow-run.Completed"]
threshold: 1
within: 0
match:
prefect.resource.id: "prefect.flow-run.*"
match_related:
prefect.resource.name: "flow_a/dep-a" # <flow>/<deployment>
actions:
- type: run-deployment
source: selected
deployment_id: "b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2" # dep-b UUID
schedule_after: 0
- name: "Trigger C after B completes"
description: "Start dep-c after dep-b completes"
enabled: true
trigger:
type: event
posture: Reactive
expect: ["prefect.flow-run.Completed"]
threshold: 1
within: 0
match:
prefect.resource.id: "prefect.flow-run.*"
match_related:
prefect.resource.name: "flow_b/dep-b"
actions:
- type: run-deployment
source: selected
deployment_id: "c3c3c3c3-c3c3-c3c3-c3c3-c3c3c3c3c3c3" # dep-c UUID
schedule_after: 0
- Apply it at startup:
prefect automations create --from-file automation_chain.yaml
Notes:
- This creates new automations each time you run it. To avoid duplicates, either:
- Make your startup script idempotent (check existing by name and delete/update), or
- Use the Python client (Option 3) to upsert by name.
Option 3: Use the Python client to create/update automations
- Most flexible, lets you check for existing automations and update instead of duplicating.
```
import asyncio
from uuid import UUID
from prefect.automations import Automation, EventTrigger, Posture
from prefect.events.actions import RunDeployment
from prefect.client.orchestration import get_client
async def upsert_automation(name: str, trigger: EventTrigger, action: RunDeployment):
async with get_client() as client:
# Try to find by name
existing = [a async for a in client.read_automations()] # list all
found = next((a for a in existing if a.name == name), None)
payload = Automation(
name=name,
enabled=True,
trigger=trigger,
actions=[action],
)
if found:
payload.id = found.id
await payload.aupdate()Marvin
03/18/2026, 5:47 PMto_deployment(cron="...") or your preferred deployment method). Then use automations to chain B and C.
- Match by deployment name as shown (flow_name/deployment_name). If you prefer, you can match by the related resource ID of the deployment instead.
- In docker-compose, ensure the script/container has PREFECT_API_URL pointed at your server and any credentials (if using Cloud).
- If you’ll re-run this on every compose up, favor the Python “upsert” approach to avoid duplicate automations.
If you share how you’re creating your three deployments today (Python code, prefect.yaml, or CLI), I can tailor a ready-to-run snippet that wires up the exact names/IDs you’re using.Pav Staz
03/18/2026, 6:26 PMMarvin
03/18/2026, 6:35 PMMarvin
03/18/2026, 6:39 PMprefect automations ls
prefect automations ls --output json
If these error, please share the traceback — that points to a backend data/validation issue. If they succeed, it’s likely a frontend-only issue.
2) Check versions match
- In the container that ran your deployment code:
prefect version
- In the server/UI container:
prefect version
If they’re far apart, upgrade/pin them to the same 3.x version. Version drift can create records (e.g., trigger schemas) the UI doesn’t understand.
3) Browser and network checks
- Try a hard refresh, an incognito window, or a different browser.
- Open DevTools (Console + Network) on the Automations tab and see if any request fails (e.g., a 500 with a JSON parse/validation error). If you can, share the failing request path and the error.
4) Server logs
- Tail the Prefect server logs while loading the Automations tab to catch any exceptions from the API.
Workarounds/unblockers while we debug:
- You can still manage automations via CLI even if the UI page is stuck:
- List:
prefect automations ls --output json
- Inspect one:
prefect automations inspect <automation-id-or-name>
- Pause/disable any suspect ones (in case a malformed record is tripping rendering):
prefect automations pause --id <id>
- Since you used “Option 1” (deployment triggers in code), you can temporarily re-serve the same deployments without triggers to confirm the UI loads again:
- Comment out the triggers=[...] in your to_deployment(...) calls
- Re-run your deploy/serve step
- Reload the Automations tab
If it’s reproducible, I’d love these details to help us pinpoint it:
- Prefect version(s) for both server and the environment that created the triggers
- A minimal snippet of the DeploymentEventTrigger you used (just the fields/shape)
- The failing request/stack trace from the browser console or server logs
Once we have that, I can suggest a precise fix or open a bug for the UI if needed.Pav Staz
03/19/2026, 10:24 AMMarvin
03/19/2026, 10:24 AMMarvin
03/19/2026, 10:28 AMmatch_related:
prefect.resource.name: "iit_dbt_run/iit_dbt_run"
That would match events related to the iit_dbt_run deployment itself, not the upstream iit_pre_dbt_syncs deployment. So it won’t fire when iit_pre_dbt_syncs completes.
What to change
- Point the downstream trigger’s match_related at the upstream deployment.
- Match by deployment name or by deployment id; both work. Name is simpler, ID is most reliable.
Example fixes for your code
Option A: Match by upstream deployment name
_iit_dbt_run_._serve_(
_name_="iit_dbt_run",
_triggers_=[
_DeploymentEventTrigger_(
_expect_={"prefect.flow-run.Completed"},
_match_related_={
"prefect.resource.name": "iit_pre_dbt_syncs" # the upstream deployment name only
# optionally also pin the role:
# "prefect.resource.role": "deployment"
},
# optionally give a small window to tolerate event latency:
# _within_=timedelta(seconds=10),
)
]
)
Option B: Match by upstream deployment ID (most robust)
- First, get the upstream deployment id:
prefect deployment ls --output json
- Then set:
_match_related_={
"prefect.resource.id": "prefect.deployment.<UPSTREAM_DEPLOYMENT_UUID>"
}
Important notes
- Do not use flow_name/deployment_name in match_related. Event resources use:
- prefect.resource.id: "prefect.deployment.<uuid>"
- prefect.resource.name: "<deployment-name>"
- prefect.resource.role: "deployment"
- Ensure the upstream flow run was started from a deployment. If you ran the flow ad-hoc (not via deployment), the Completed event will not include a related deployment resource and your match won’t hit. You can match by flow instead in that case:
_match_related_={
"prefect.resource.name": "iit_pre_dbt_syncs_flow", # the flow name
"prefect.resource.role": "flow"
}
How to debug quickly
1) Stream events while you run the upstream deployment:
prefect event stream --format json
2) Look for a “prefect.flow-run.Completed” event for iit_pre_dbt_syncs. Confirm it has a related resource like:
{
"prefect.resource.id": "prefect.deployment.<uuid>",
"prefect.resource.role": "deployment",
"prefect.resource.name": "iit_pre_dbt_syncs"
}
3) Adjust your match_related to exactly match those labels.
Optional: increase the window
- If you suspect minor delays, add a small within window:
_within_=timedelta(seconds=10)
Reactive triggers allow 0, but a few seconds gives breathing room.
Recap
- Point match_related at the upstream deployment’s resource labels, not the downstream.
- Prefer matching by id or by the bare deployment name (not flow/deployment).
- Confirm the upstream run emits a Completed event with a deployment-related resource using prefect event stream.
If you share the exact upstream deployment name and what you see in the event stream for its Completed event, I’ll give you a copy-paste-correct trigger block.Pav Staz
03/19/2026, 1:09 PM2026-03-19T13:05:49.360304849Z File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 2363, in _handle_dbapi_exception
2026-03-19T13:05:49.360307889Z raise sqlalchemy_exception.with_traceback(exc_info[2]) from e
2026-03-19T13:05:49.360310829Z File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context
2026-03-19T13:05:49.360313849Z self.dialect.do_execute(
2026-03-19T13:05:49.360316689Z File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute
2026-03-19T13:05:49.360319769Z cursor.execute(statement, parameters)
2026-03-19T13:05:49.360322619Z File "/usr/local/lib/python3.11/site-packages/sqlalchemy/dialects/sqlite/aiosqlite.py", line 182, in execute
2026-03-19T13:05:49.360325659Z self._adapt_connection._handle_exception(error)
2026-03-19T13:05:49.360328519Z File "/usr/local/lib/python3.11/site-packages/sqlalchemy/dialects/sqlite/aiosqlite.py", line 342, in _handle_exception
2026-03-19T13:05:49.360335879Z raise error
2026-03-19T13:05:49.360338759Z File "/usr/local/lib/python3.11/site-packages/sqlalchemy/dialects/sqlite/aiosqlite.py", line 164, in execute
2026-03-19T13:05:49.360341799Z self.await_(_cursor.execute(operation, parameters))
2026-03-19T13:05:49.360344758Z File "/usr/local/lib/python3.11/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
2026-03-19T13:05:49.360347758Z return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
2026-03-19T13:05:49.360350778Z ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2026-03-19T13:05:49.360353618Z File "/usr/local/lib/python3.11/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
2026-03-19T13:05:49.360356638Z value = await result
2026-03-19T13:05:49.360359448Z ^^^^^^^^^^^^
2026-03-19T13:05:49.360362258Z File "/usr/local/lib/python3.11/site-packages/aiosqlite/cursor.py", line 40, in execute
2026-03-19T13:05:49.360365238Z await self._execute(self._cursor.execute, sql, parameters)
2026-03-19T13:05:49.360368148Z File "/usr/local/lib/python3.11/site-packages/aiosqlite/cursor.py", line 32, in _execute
2026-03-19T13:05:49.360371398Z return await self._conn._execute(fn, *args, **kwargs)
2026-03-19T13:05:49.360374288Z ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2026-03-19T13:05:49.360377148Z File "/usr/local/lib/python3.11/site-packages/aiosqlite/core.py", line 160, in _execute
2026-03-19T13:05:49.360380128Z return await future
2026-03-19T13:05:49.360382928Z ^^^^^^^^^^^^
2026-03-19T13:05:49.360385727Z File "/usr/local/lib/python3.11/site-packages/aiosqlite/core.py", line 63, in _connection_worker_thread
2026-03-19T13:05:49.360388737Z result = function()
2026-03-19T13:05:49.360391577Z ^^^^^^^^^^
2026-03-19T13:05:49.360394377Z sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) database is locked
2026-03-19T13:05:49.360397357Z [SQL: UPDATE deployment SET last_polled=:last_polled, status=:status, updated=deployment.updated WHERE deployment.id IN (:id_1_1) OR deployment.work_queue_id IN (SELECT 1 FROM (SELECT 1) WHERE 1!=1)]
2026-03-19T13:05:49.360400537Z [parameters: {'last_polled': '2026-03-19 13:05:49.334934', 'status': 'READY', 'id_1_1': '8d6e3607-3b21-49e1-b183-e37d6b55f43b'}]
2026-03-19T13:05:49.360407667Z (Background on this error at: <https://sqlalche.me/e/20/e3q8>)Marvin
03/19/2026, 1:09 PMMarvin
03/19/2026, 1:09 PMUPDATE deployment
SET last_polled=:last_polled, status=:status, updated=deployment.updated
WHERE deployment.id IN (:id_1_1) ...
- SQLite allows only a single writer at a time. If another transaction has the DB locked (even briefly), concurrent writers will hit this OperationalError.
Why it’s happening in Prefect
- With Prefect Server on SQLite, multiple background services (API, scheduler, automations, etc.) can write at the same time. Under load or with multiple server instances, SQLite’s single-writer model leads to “database is locked”.
- This is not a Prefect-specific bug; it’s a limitation of SQLite under concurrent writes. It can cause missed/slow updates (e.g., deployment status, last_polled) which might explain automations not triggering promptly.
How to fix (recommended → most robust)
1) Move the server DB to Postgres (recommended for any multi-user or automation-heavy setup)
- In docker-compose, add Postgres and point Prefect to it via PREFECT_API_DATABASE_CONNECTION_URL:
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: prefect
POSTGRES_PASSWORD: prefect
POSTGRES_DB: prefect
volumes:
- pgdata:/var/lib/postgresql/data
prefect:
image: prefecthq/prefect:3-python3.11
command: prefect server start
environment:
PREFECT_API_DATABASE_CONNECTION_URL: <postgresql+asyncpg://prefect:prefect@postgres:5432/prefect>
PREFECT_SERVER_API_HOST: 0.0.0.0
ports:
- "4200:4200"
depends_on:
- postgres
volumes:
pgdata:
- Prefect will run migrations on startup; no extra steps needed.
2) If you must stay on SQLite (dev only)
- Ensure only a single Prefect server container is running (no duplicates or scaled replicas).
- Avoid sharing the SQLite DB file across containers or network filesystems.
- Increase the SQLite busy timeout so writers wait instead of failing immediately:
- Set the connection URL with a timeout, e.g.:
PREFECT_API_DATABASE_CONNECTION_URL=sqlite+aiosqlite:////root/.prefect/prefect.db?timeout=30.0
- Then restart the server.
- As a last resort, restart the Prefect server to clear a stuck lock.
Quick sanity checks
- Confirm one server instance:
- Check docker ps for duplicate Prefect containers.
- Tail server logs while you run upstream/downstream deployments to see if locks correlate with missed automations.
- If automations still don’t fire, run:
prefect event stream --format json
Trigger your upstream deployment and verify you see a “prefect.flow-run.Completed” event with a related resource for the upstream deployment. If it’s missing, your trigger’s match won’t hit.
Please share:
- The second error you mentioned
- Your docker-compose for Prefect + DB (to spot anything that could cause concurrent writes)
- Whether you’re using Postgres or SQLite right now
I’ll tailor exact changes once I see those.Pav Staz
03/19/2026, 1:24 PMMarvin
03/19/2026, 1:24 PMMarvin
03/19/2026, 1:25 PMprefect deployment ls --output json
- Update your trigger to match the exact upstream deployment name, or better, match by deployment ID:
_match_related_={
"prefect.resource.id": "prefect.deployment.<UPSTREAM_DEPLOYMENT_UUID>"
}
- Also make sure the upstream run is started as a deployment (not a manual flow run), otherwise the Completed event won’t include a related deployment and your match won’t hit.
- Quick debug:
prefect event stream --format json
Then run the upstream deployment. In the Completed event, confirm there’s a related resource like:
{
"prefect.resource.role": "deployment",
"prefect.resource.name": "iit_pre_dbt_syncs",
"prefect.resource.id": "prefect.deployment.<uuid>"
}
Adjust your match to exactly match these labels.
2) SQLite “database is locked” (why updates/automations can be flaky)
- You’re on SQLite and have multiple long-running containers. While flow-run containers are fine, the lock is from the Prefect API trying to update the deployments table. SQLite allows only one writer at a time; concurrent writes can hit OperationalError and delay/skip updates like last_polled/status, which can affect automations.
- Fix options:
- Preferred: switch Prefect Server to Postgres in docker-compose:
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: prefect
POSTGRES_PASSWORD: prefect
POSTGRES_DB: prefect
volumes:
- pgdata:/var/lib/postgresql/data
prefect:
image: prefecthq/prefect:3-python3.11
command: prefect server start
environment:
PREFECT_API_DATABASE_CONNECTION_URL: <postgresql+asyncpg://prefect:prefect@postgres:5432/prefect>
PREFECT_SERVER_API_HOST: 0.0.0.0
ports:
- "4200:4200"
depends_on:
- postgres
volumes:
pgdata:
- If you must stay on SQLite (dev only):
- Ensure only one Prefect server container is running.
- Don’t share the SQLite file across containers/hosts.
- Increase the SQLite busy timeout so writers wait instead of failing:
PREFECT_API_DATABASE_CONNECTION_URL=sqlite+aiosqlite:////root/.prefect/prefect.db?timeout=30.0
- Restart the server container after changes.
3) Sanity checks to run now
- Confirm you only have a single server instance:
- Check for any containers running “prefect server start”; there should be exactly one.
- Verify automations exist and are enabled:
prefect automations ls --output json
- Stream events while testing:
prefect event stream --format json
Then:
prefect deployment run "iit_pre_dbt_syncs/<deployment-name-exact>"
If you don’t see a Completed event with a related deployment, your match won’t fire.
If you can share:
- The output of prefect deployment ls --output json for both upstream and downstream (to confirm exact names and IDs)
- Your server’s DB connection URL (env var value) and a quick snippet of your docker-compose for the Prefect server
I’ll give you a copy-paste-correct trigger match and any compose tweaks needed.Pav Staz
03/19/2026, 1:34 PMMarvin
03/19/2026, 1:34 PMMarvin
03/19/2026, 1:36 PMsqlalchemy.exc.OperationalError: (sqlite3.OperationalError) database is locked
- The ?timeout=30.0 just makes writers wait longer; it does not remove the contention. With Prefect’s background services, it’s common to exceed that window.
Two concrete fixes (recommended first)
1) Move the Prefect Server DB to Postgres (recommended)
- This eliminates SQLite’s single-writer bottleneck and is the supported option for multi-user/automation-heavy setups.
- Compose example (swap your server service to use Postgres):
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: prefect
POSTGRES_PASSWORD: prefect
POSTGRES_DB: prefect
volumes:
- pgdata:/var/lib/postgresql/data
server:
image: <http://registry.com/prefect:prod_latest|registry.com/prefect:prod_latest>
command: prefect server start --host 0.0.0.0
ports:
- "4200:4200"
environment:
PREFECT_PROFILES_ACTIVE: server
PREFECT_SERVER_API_AUTH_STRING: ${PREFECT_SERVER_API_AUTH_STRING}
PREFECT_API_AUTH_STRING: ${PREFECT_SERVER_API_AUTH_STRING}
PREFECT_UI_API_URL: ${DOMAIN_URL_API}
PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED: true
PREFECT_API_DATABASE_CONNECTION_URL: <postgresql+asyncpg://prefect:prefect@postgres:5432/prefect>
depends_on:
- postgres
volumes:
- prefect_data:/root/.prefect
volumes:
pgdata:
prefect_data:
Notes:
- Prefect Server will run DB migrations automatically on startup.
- If you need to preserve historical data from SQLite, there’s no built-in migration from SQLite → Postgres; most users start fresh (or export/import selectively).
2) If you must stay on SQLite (dev-only)
- Keep only one server container.
- Keep the DB file on a local Docker volume (not NFS/remote FS) — you already do.
- Remove PREFECT_API_DATABASE_CONNECTION_URL from all non-server services. Only the server should know about the DB connection; clients should only talk to PREFECT_API_URL. In your compose, these deploy/test containers set the DB URL needlessly:
PREFECT_API_DATABASE_CONNECTION_URL: sqlite+aiosqlite:////root/.prefect/prefect.db?timeout=30.0
Please remove that from every service except server.
- Optionally, try WAL mode to reduce writer stalls (still not as good as Postgres):
- Add a tiny init step to set PRAGMAs inside the server before it starts:
command: bash -lc "
python - <<'PY'
import sqlite3, os
db='/root/.prefect/prefect.db'
os.makedirs('/root/.prefect', exist_ok=True)
con=sqlite3.connect(db)
con.execute('PRAGMA journal_mode=WAL;')
con.execute('PRAGMA synchronous=NORMAL;')
con.commit(); con.close()
PY
&& prefect server start --host 0.0.0.0
"
- This helps some workloads, but won’t eliminate locks under concurrent writes.
Why remove the DB URL from non-server services?
- Those containers should only call the Server API (`PREFECT_API_URL=http://server:4200/api`). Pointing them at a SQLite file path does nothing helpful and can accidentally initialize their own local SQLite files if anything attempts a direct DB connection in-process. It won’t fix the server’s locking and can add confusion.
Quick sanity checklist
- Exactly one server process is running (no duplicate “prefect server start” containers).
- Only the server has `PREFECT_API_DATABASE_CONNECTION_URL`; other services have only PREFECT_API_URL (and auth).
- If staying on SQLite, apply WAL PRAGMAs and keep the timeout; expect occasional locks under load anyway.
- Best: switch to Postgres; this will resolve the lock errors.
If you want, paste:
- The exact lock trace with timestamps from the server container logsMarvin
03/19/2026, 1:36 PMdocker ps filtered to the server
I’ll double-check there’s only a single server and confirm no other process is touching the SQLite file. If you’d like, I can also provide a ready-to-use compose override for Postgres tailored to your current file.Pav Staz
03/19/2026, 1:49 PMMarvin
03/19/2026, 1:51 PMMarvin
03/19/2026, 1:52 PMprintenv | grep PREFECT_
prefect config view
curl -sSf <http://server:4200/api/health>
curl -sSf <https://prefect.cwc-data.uk/api/health>
prefect profile ls
- If prefect config view shows PREFECT_API_URL = https://prefect.cwc-data.uk/api, your env is not applied or a profile is overriding. Env vars should take precedence; if they’re missing, the profile wins.
Concrete fixes
- Ensure every deployment service inherits x-prefect-deploy-common so they get:
- PREFECT_API_URL=http://server:4200/api
- PREFECT_API_AUTH_STRING (matching the server)
- Remove PREFECT_UI_API_URL from client containers (it’s only for the UI; yours is only on the server, which is fine).
- If your image has a default profile that points to your domain, either:
- Set PREFECT_PROFILES_ACTIVE: default in all client services, or
- Explicitly export PREFECT_API_URL in each client service (which you do) and confirm it’s present with printenv.
- If your code calls load_profile("cloud") or similar, remove it; prefer the env var.
A) SQLite “database is locked”
What’s happening
- Prefect Server writes frequently (scheduler, events, automations). SQLite’s single-writer model causes routine lock contention under this load; timeout=30 only makes writers wait, it doesn’t remove the bottleneck.
Critical compose changes
- Only the server should have PREFECT_API_DATABASE_CONNECTION_URL. In your compose, it’s set for ALL services via x-prefect-deploy-common:
PREFECT_API_DATABASE_CONNECTION_URL: sqlite+aiosqlite:////root/.prefect/prefect.db?timeout=30.0
Remove that line from x-prefect-deploy-common entirely. Clients should not know about the DB; they only hit the API:
PREFECT_API_URL: <http://server:4200/api>
- Keep the DB URL only on the server service.
Best fix (recommended)
- Switch the server to Postgres; this eliminates the lock errors:
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: prefect
POSTGRES_PASSWORD: prefect
POSTGRES_DB: prefect
volumes:
- pgdata:/var/lib/postgresql/data
server:
image: <http://registry.com/prefect:prod_latest|registry.com/prefect:prod_latest>
command: prefect server start --host 0.0.0.0
ports: ["4200:4200"]
environment:
PREFECT_SERVER_API_AUTH_STRING: ${PREFECT_SERVER_API_AUTH_STRING}
PREFECT_API_AUTH_STRING: ${PREFECT_SERVER_API_AUTH_STRING}
PREFECT_UI_API_URL: ${DOMAIN_URL_API}
PREFECT_API_DATABASE_CONNECTION_URL: <postgresql+asyncpg://prefect:prefect@postgres:5432/prefect>
depends_on: [postgres]
volumes:
- prefect_data:/root/.prefect
volumes:
pgdata:
prefect_data:
If you must stay on SQLite (dev-only)
- Ensure exactly one server container is running.
- Keep DB on a local Docker volume (you already do).
- Consider enabling WAL mode (still not a silver bullet):
```
command: bash -lc "
python - <<'PY'
import sqlite3, osMarvin
03/19/2026, 1:52 PMAction checklist for you
1) Remove PREFECT_API_DATABASE_CONNECTION_URL from x-prefect-deploy-common (all client services).
2) Rebuild/restart. Then in a client container:
printenv | grep PREFECT_API_URL
prefect config view
curl -sSf http://server:4200/api/health
Confirm it points at <http://server:4200/api> and health returns 200.
3) If you still see the domain URL in config view, a profile in your image is overriding. Run:
prefect profile ls
prefect profile inspect <name>
Then set in the service:
PREFECT_PROFILES_ACTIVE: default
```
or explicitly override PREFECT_API_URL again (env should win).
4) Strongly consider switching the server to Postgres to end the lock errors.
If you paste:
- The output of “prefect config view” and “printenv | grep PREFECT_” from a failing container
- The output of “curl -sSf http://server:4200/api/health”
I’ll pinpoint exactly what’s overriding your API URL and provide a minimal patch.Pav Staz
03/19/2026, 1:57 PMMarvin
03/19/2026, 1:57 PMMarvin
03/19/2026, 1:59 PMretries=1, retry_delay_seconds=1800 on the upstream flow:
- If the first attempt fails, the flow run enters AwaitingRetry and is scheduled 30 minutes later.
- There is no “Completed” event until the retry actually runs and succeeds.
- So your automation won’t fire until that final success event is emitted.
- When you remove retries, your run either:
- Succeeds quickly → Completed fires → automation triggers; or
- Fails (no retries) → there’s still no Completed event, but you’re not waiting 30 minutes so it looks different.
How to confirm quickly
1) Check the upstream run’s final state in UI (or CLI):
prefect flow-run ls --name <your-run-name>
prefect flow-run inspect <flow-run-id>
If it’s “AwaitingRetry” or “Scheduled” for the retry, your Completed event hasn’t happened yet.
2) Stream events while testing:
prefect event stream --format json
- Start the upstream deployment.
- Watch for “prefect.flow-run.Completed” for it.
- If you only see “prefect.flow-run.AwaitingRetry” (and later a new “Started”), that’s why the downstream didn’t fire yet.
If you want the chain to trigger only on success (recommended for A→B→C), keep your trigger expecting Completed and let the upstream finish retrying first.
If you want to trigger earlier (e.g., on first failure scheduled for retry)
- You can broaden the trigger to include the retry state, but beware of duplicate triggers:
- Adding multiple event names can cause multiple downstream runs (on AwaitingRetry and on final Completed).
- If you truly need that, consider a more advanced trigger design (e.g., sequence/compound with “after” and a larger “within”) or separate automations with guardrails.
Safer alternatives
- Keep flow-level success chaining: expect only “prefect.flow-run.Completed” and be aware of the retry delay.
- Or move retries into specific tasks inside the upstream flow so the flow is more likely to complete in the first run.
- Or shorten retry_delay_seconds while you’re testing to prove the chain works.
Also double-check your trigger match
- Make sure you’re matching the upstream deployment exactly (by name or, better, by id). Example:
DeploymentEventTrigger(
expect={"prefect.flow-run.Completed"},
match_related={"prefect.resource.id": "prefect.deployment.<UPSTREAM_UUID>"},
# optionally give a bit of window:
# within=timedelta(seconds=10),
)
- Verify what the event actually contains (from the event stream). Related deployment appears as:
{
"prefect.resource.role": "deployment",
"prefect.resource.name": "<deployment-name>",
"prefect.resource.id": "prefect.deployment.<uuid>"
}
One more thing: your earlier SQLite “database is locked” can delay state updates and event processing
- Even if not the primary cause here, it can make this harder to observe in real time.
- Strongly consider switching the server to Postgres to eliminate lock contention, as noted earlier.
Docs for reference
- Automations and triggers: Automations
- Event stream CLI: Event stream
- Deployment triggers in code: Deployment triggers
If you can share:
- The upstream run’s final state and its event payload from prefect event stream,
- Your exact DeploymentEventTrigger block,
I can confirm the matching and suggest a dedup-safe trigger if you truly need to fire on retry scheduling.Pav Staz
03/19/2026, 4:09 PMMarvin
03/19/2026, 4:09 PMMarvin
03/19/2026, 4:10 PMlocalStorage.clear();
sessionStorage.clear();
caches?.keys?.().then(keys => keys.forEach(k => caches.delete(k)));
Then reload the page to re-prompt for auth.
- Try an incognito window as a test; if it works there, it’s definitely cached state.
Reverse proxy considerations
- Ensure the proxy forwards Authorization to /api:
- NGINX example:
proxy_set_header Authorization $http_authorization;
- Keep UI static assets and API behavior consistent; if you protect both with Basic Auth at the proxy, browsers will cache those creds per-origin — changing them can cause odd behavior until site data is cleared.
- Confirm PREFECT_UI_API_URL matches the external URL you’re serving (and that the proxy routes /api correctly).
General tips
- If you rotate PREFECT_SERVER_API_AUTH_STRING, all browsers will need to re-auth; expect to clear site data or at least re-enter credentials.
- When upgrading images, a hard refresh prevents mismatches between cached JS bundles and the new API.
If you run into it again, open DevTools → Network:
- Look for 401/403 on /api/* requests.
- Check Console for errors about fetching assets (CORS or 401 on JS files).
Share any error lines you see there and I can confirm if it’s a cached auth vs. proxy header forward issue.