Thang Kieu
12/10/2025, 7:19 AMMarvin
12/10/2025, 7:19 AMMarvin
12/10/2025, 7:21 AM/home/atlas is read-only, so the mkdir fails.
Details:
- In Prefect 3.x the home directory defaults to ~/.prefect. Prefect creates this directory on startup.
- In your case it’s trying to create /home/atlas/prefect, which means PREFECT_HOME is likely set to that path (or your image changed the default). Either way, that location isn’t writable in the running container.
How to fix
Pick one of these:
1) Point Prefect home to a writable path
- Set an env var in your job/pool/deployment so the runtime uses a writable directory:
PREFECT_HOME=/tmp/prefect
- For Docker/K8s/ECS jobs, add this to the worker pool’s default job variables or the deployment’s job variables/env.
2) Make the target path writable in your image
- If you run as a non-root user (e.g., atlas), create and chown the directory in your Dockerfile:
ENV PREFECT_HOME=/home/atlas/.prefect
RUN mkdir -p $PREFECT_HOME && chown -R atlas:atlas $PREFECT_HOME
- Then ensure the container is not started with a read-only root filesystem (or mount a writable volume there).
3) Mount a volume at the home path
- When you start the container, mount a volume to the Prefect home path:
-v prefect-data:/home/atlas/.prefect
- Or if you must keep a read-only rootfs, add a writable tmpfs/emptyDir at that path.
A couple of checks:
- Verify whether you’ve set PREFECT_HOME (the default would be ~/.prefect, not ~/prefect). Seeing /home/atlas/prefect implies an override.
- Confirm which worker type you’re using (Docker, Kubernetes, ECS) and whether it enforces a read-only root filesystem.
- If you want, share your Dockerfile snippet and deployment/job variables, and I can suggest the exact change.Thang Kieu
12/10/2025, 7:26 AMFROM python-image AS production
ARG BUILD_USER
ARG BUILD_USER_ID
ARG BUILD_GROUP
ARG BUILD_GROUP_ID=1001
ARG BUILD_KEY_PASS
ARG UV_INDEX_ATLAS_V16_USERNAME
ARG UV_INDEX_ATLAS_V16_PASSWORD
ARG UV_INDEX_AIBOTS_USERNAME
ARG UV_INDEX_AIBOTS_PASSWORD
WORKDIR $HOME/$BUILD_USER/app
# Install dependencies
ENV UV_SYSTEM_PYTHON=1 \
UV_PROJECT_ENVIRONMENT="/usr/local"
COPY pyproject.toml uv.lock README.md ./
USER root
RUN pip install --no-cache-dir --upgrade pip=='25.1.1' uv=='0.7.11' && \
UV_INDEX_ATLAS_V16_USERNAME=$UV_INDEX_ATLAS_V16_USERNAME UV_INDEX_ATLAS_V16_PASSWORD=$UV_INDEX_ATLAS_V16_PASSWORD \
UV_INDEX_AIBOTS_USERNAME=$UV_INDEX_AIBOTS_USERNAME UV_INDEX_AIBOTS_PASSWORD=$UV_INDEX_AIBOTS_PASSWORD \
uv pip install --no-cache -r pyproject.toml --system
# Install the source code as a Python package
COPY src ./src
RUN uv pip install --system .
ENV PREFECT_HOME=/home/$BUILD_USER/prefect
RUN chown -R "$BUILD_GROUP:$BUILD_USER" "$PREFECT_HOME"
USER $BUILD_USER_IDThang Kieu
12/10/2025, 7:26 AMFROM python-image AS production
ARG BUILD_USER
ARG BUILD_USER_ID
ARG BUILD_GROUP
ARG BUILD_GROUP_ID=1001
ARG BUILD_KEY_PASS
ARG UV_INDEX_ATLAS_V16_USERNAME
ARG UV_INDEX_ATLAS_V16_PASSWORD
ARG UV_INDEX_AIBOTS_USERNAME
ARG UV_INDEX_AIBOTS_PASSWORD
WORKDIR $HOME/$BUILD_USER/app
# Install dependencies
ENV UV_SYSTEM_PYTHON=1 \
UV_PROJECT_ENVIRONMENT="/usr/local"
COPY pyproject.toml uv.lock README.md ./
USER root
RUN pip install --no-cache-dir --upgrade pip=='25.1.1' uv=='0.7.11' && \
UV_INDEX_ATLAS_V16_USERNAME=$UV_INDEX_ATLAS_V16_USERNAME UV_INDEX_ATLAS_V16_PASSWORD=$UV_INDEX_ATLAS_V16_PASSWORD \
UV_INDEX_AIBOTS_USERNAME=$UV_INDEX_AIBOTS_USERNAME UV_INDEX_AIBOTS_PASSWORD=$UV_INDEX_AIBOTS_PASSWORD \
uv pip install --no-cache -r pyproject.toml --system
# Install the source code as a Python package
COPY src ./src
RUN uv pip install --system .
ENV PREFECT_HOME=/home/$BUILD_USER/prefect
RUN chown -R "$BUILD_GROUP:$BUILD_USER" "$PREFECT_HOME"
USER $BUILD_USER_IDMarvin
12/10/2025, 7:26 AMMarvin
12/10/2025, 7:30 AMPREFECT_HOME on startup. In your image it’s set to /home/$BUILD_USER/prefect (note: not the default ~/.prefect).
- Your container at runtime is either:
- Running with a read-only root filesystem, or
- Not mounting a writable volume over /home/$BUILD_USER, so Prefect can’t create the directory there.
Also, in your Dockerfile the directory isn’t created and may not be owned by the runtime UID you switch to.
Issues in the Dockerfile to fix
- You never create the PREFECT_HOME directory before switching users.
- You chown using names ("$BUILD_GROUP:$BUILD_USER") but switch to a numeric user (USER $BUILD_USER_ID). If those names don’t exist in the image, the chown will fail (or do nothing). It’s safer to chown with IDs or create the user/group.
- WORKDIR $HOME/$BUILD_USER/app is fragile (depends on what $HOME is during build). Prefer explicit /home/${BUILD_USER}/app after the user exists.
- If your worker is starting the container with a read-only rootfs, this path must be backed by a writable mount.
Recommended Dockerfile changes (minimal)
# (after you've installed deps and your package)
# Create the runtime user/group if they don’t already exist
# If your base image already has them, you can skip this block.
RUN groupadd -g ${BUILD_GROUP_ID} ${BUILD_GROUP} || true && \
useradd -u ${BUILD_USER_ID} -g ${BUILD_GROUP_ID} -m -d /home/${BUILD_USER} ${BUILD_USER} || true
# Set HOME and Prefect home
ENV HOME=/home/${BUILD_USER}
ENV PREFECT_HOME=${HOME}/.prefect
# Ensure Prefect home exists and is writable by the runtime UID:GID
RUN mkdir -p "${PREFECT_HOME}" && chown -R ${BUILD_USER_ID}:${BUILD_GROUP_ID} "${PREFECT_HOME}"
# Use a stable workdir
WORKDIR /home/${BUILD_USER}/app
# Drop privileges
USER ${BUILD_USER_ID}:${BUILD_GROUP_ID}
If your worker runs containers with a read-only rootfs
- You must give Prefect a writable mount at PREFECT_HOME. In a Prefect Docker work pool/deployment, set job variables like:
{
"volumes": [
"prefect-data:/home/atlas/.prefect" // omit :ro so it’s writable
]
// If you explicitly set a read-only rootfs elsewhere, ensure this stays writable
// "container_create_kwargs": { "read_only": true } // only if you truly need it
}
- Alternatively, point Prefect to a writable tmpfs:
{
"container_create_kwargs": {
"tmpfs": {
"/tmp": ""
}
},
"env": {
"PREFECT_HOME": "/tmp/prefect"
}
}
Quick workaround (no image changes)
- Set a writable PREFECT_HOME via deployment/job variables:
PREFECT_HOME=/tmp/prefect
Next steps / sanity checks
- Are you using a Prefect Docker worker? If yes, verify your work pool/deployment doesn’t set a read-only rootfs via container_create_kwargs.read_only: true.
- Ensure you mount a volume for Prefect home if you want state (DB, profiles, local storage) to persist across runs.
- If you prefer /home/atlas/prefect without the leading dot, that’s fine—just make sure it exists and is writable for the runtime UID.
If you share your work pool’s job variables (volumes/container_create_kwargs) I can tailor the exact settings.Thang Kieu
12/10/2025, 8:14 AMMarvin
12/10/2025, 8:14 AMMarvin
12/10/2025, 8:15 AMmount | grep ' / ' and mount | grep '/home/atlas' to see if the root or that path is read‑only (look for (ro,...)).
- env | grep PREFECT_HOME to confirm the path Prefect is using.
- Read‑only root filesystem enabled by the worker
- In a Prefect Docker work pool/deployment, this can be set via container_create_kwargs.read_only: true or by mounting your home path as :ro.
- Inspect the failing container:
- docker inspect <container-id> --format '{{json .HostConfig.ReadonlyRootfs}}'
- docker inspect <container-id> --format '{{json .HostConfig.Binds}}'
- Look for ReadonlyRootfs: true or any bind like ...:/home/atlas:ro or ...:/home/atlas/prefect:ro.
- Path exists but Prefect still tried to mkdir
- Prefect only calls mkdir if PREFECT_HOME didn’t appear to exist. If the rootfs was read‑only at startup, the mkdir would fail with EROFS even if you later created the directory in another run or container. That would explain why you now see it present and writable.
Quick ways to unblock
- Easiest test: set a writable home in your deployment/job variables and use a tmpfs for it:
env:
PREFECT_HOME: /tmp/prefect
container_create_kwargs:
tmpfs:
/tmp: ""
- Or mount a writable volume at your current home:
volumes:
- prefect-data:/home/atlas/prefect
- If you intentionally run with read‑only rootfs, keep it but ensure a writable mount backs PREFECT_HOME as above.
Notes on your Dockerfile
- You did ENV PREFECT_HOME=/home/$BUILD_USER/prefect (no dot). That’s fine, just ensure a writable mount there at runtime.
- Consider creating the directory and chown with IDs before dropping privileges, and avoid the nested WORKDIR $HOME/$BUILD_USER/app pattern which created /home/atlas/atlas/app.
If you can share:
- The work pool/deployment job variables (especially volumes and container_create_kwargs)
- A docker inspect of the failing container’s ReadonlyRootfs and Binds
…I can point to the exact setting that’s making the FS read‑only.Thang Kieu
12/10/2025, 8:39 AMbaseJobTemplate.json: "{\n \"variables\": {\n \"type\": \"object\",\n \"properties\": {\n \"env\": {\n \"type\": \"object\",\n \"title\": \"Environment Variables\",\n \"description\": \"Environment variables to set when starting a flow run.\",\n \"additionalProperties\": {\n \"anyOf\": [\n { \"type\": \"string\" },\n { \"type\": \"null\" }\n ]\n }\n },\n \"name\": {\n \"anyOf\": [\n { \"type\": \"string\" },\n { \"type\": \"null\" }\n ],\n \"title\": \"Name\",\n \"description\": \"Name given to infrastructure created by a worker.\"\n },\n \"image\": {\n \"anyOf\": [\n { \"type\": \"string\" },\n { \"type\": \"null\" }\n ],\n \"title\": \"Image\",\n \"default\": \"<http://registry.sgts.gitlab-dedicated.com/wog/gvt/aibots/aibots/aibots-apps/orchestrator/prefect:3.6.5\|registry.sgts.gitlab-dedicated.com/wog/gvt/aibots/aibots/aibots-apps/orchestrator/prefect:3.6.5\>",\n \"examples\": [\"<http://docker.io/prefecthq/prefect:3-latest\|docker.io/prefecthq/prefect:3-latest\>"],\n \"description\": \"The image reference of a container image to use for created jobs. If not set, the latest Prefect image will be used.\"\n },\n \"labels\": {\n \"type\": \"object\",\n \"title\": \"Labels\",\n \"description\": \"Labels applied to infrastructure created by a worker.\",\n \"additionalProperties\": { \"type\": \"string\" }\n },\n \"command\": {\n \"anyOf\": [\n { \"type\": \"string\" },\n { \"type\": \"null\" }\n ],\n \"title\": \"Command\",\n \"description\": \"The command to use when starting a flow run. In most cases, this should be left blank and the command will be automatically generated by the worker.\"\n },\n \"namespace\": {\n \"type\": \"string\",\n \"title\": \"Namespace\",\n \"default\": \"default\",\n \"description\": \"The Kubernetes namespace to create jobs within.\"\n },\n \"backoff_limit\": {\n \"type\": \"integer\",\n \"title\": \"Backoff Limit\",\n \"default\": 0,\n \"minimum\": 0,\n \"description\": \"The number of times Kubernetes will retry a job after pod eviction. If set to 0, Prefect will reschedule the flow run when the pod is evicted.\"\n },\n \"stream_output\": {\n \"type\": \"boolean\",\n \"title\": \"Stream Output\",\n \"default\": true,\n \"description\": \"If set, output will be streamed from the job to local standard output.\"\n },\n \"cluster_config\": {\n \"anyOf\": [\n { \"$ref\": \"#/definitions/KubernetesClusterConfig\" },\n { \"type\": \"null\" }\n ],\n \"description\": \"The Kubernetes cluster config to use for job creation.\"\n },\n \"finished_job_ttl\": {\n \"type\": \"integer\",\n \"default\": 60,\n \"title\": \"Finished Job TTL\",\n \"description\": \"The number of seconds to retain jobs after completion. If set, finished jobs will be cleaned up by Kubernetes after the given delay. If not set, jobs will be retained indefinitely.\"\n },\n \"image_pull_policy\": {\n \"enum\": [\"IfNotPresent\", \"Always\", \"Never\"],\n \"type\": \"string\",\n \"title\": \"Image Pull Policy\",\n \"default\": \"IfNotPresent\",\n \"description\": \"The Kubernetes image pull policy to use for job containers.\"\n },\n \"service_account_name\": {\n \"anyOf\": [\n { \"type\": \"string\" },\n { \"type\": \"null\" }\n ],\n \"title\": \"Service Account Name\",\n \"description\": \"The Kubernetes service account to use for job creation.\"\n },\n \"job_watch_timeout_seconds\": {\n \"anyOf\": [\n { \"type\": \"integer\" },\n { \"type\": \"null\" }\n ],\n \"title\": \"Job Watch Timeout Seconds\",\n \"description\": \"Number of seconds to wait for each event emitted by a job before timing out. If not set, the worker will wait for each event indefinitely.\"\n },\n \"pod_watch_timeout_seconds\": {\n \"type\": \"integer\",\n \"title\": \"Pod Watch Timeout Seconds\",\n \"default\": 60,\n \"description\": \"Number of seconds to watch for pod creation before timing out.\"\n }\n },\n \"definitions\": {\n \"KubernetesClusterConfig\": {\n \"type\": \"object\",\n \"title\": \"KubernetesClusterConfig\",\n \"required\": [\"config\", \"context_name\"],\n \"properties\": {\n \"config\": {\n \"type\": \"object\",\n \"title\": \"Config\",\n \"description\": \"The entire contents of a kubectl config file.\",\n \"additionalProperties\": true\n },\n \"context_name\": {\n \"type\": \"string\",\n \"title\": \"Context Name\",\n \"description\": \"The name of the kubectl context to use.\"\n }\n }\n }\n }\n },\n \"job_configuration\": {\n \"env\": \"{{ env }}\",\n \"name\": \"{{ name }}\",\n \"labels\": \"{{ labels }}\",\n \"command\": \"{{ command }}\",\n \"namespace\": \"{{ namespace }}\",\n \"job_manifest\": {\n \"apiVersion\": \"batch/v1\",\n \"kind\": \"Job\",\n \"metadata\": {\n \"labels\": \"{{ labels }}\",\n \"namespace\": \"{{ namespace }}\",\n \"generateName\": \"{{ name }}-\"\n },\n \"spec\": {\n \"template\": {\n \"spec\": {\n \"parallelism\": 1,\n \"completions\": 1,\n \"restartPolicy\": \"Never\",\n \"serviceAccountName\": \"{{ service_account_name }}\",\n \"imagePullSecrets\": [\n { \"name\": \"gitlab-registry-secrets\" }\n ],\n \"securityContext\": {\n \"runAsUser\": 1001,\n \"runAsNonRoot\": true,\n \"fsGroup\": 1001,\n \"seccompProfile\": { \"type\": \"RuntimeDefault\" }\n },\n \"volumes\": [\n { \n \"name\": \"temp-volume\", \n \"emptyDir\": {\n \"sizeLimit\": \"5Gi\"\n }\n }\n ],\n \"containers\": [\n {\n \"name\": \"prefect-job\",\n \"image\": \"{{ image }}\",\n \"imagePullPolicy\": \"{{ image_pull_policy }}\",\n \"args\": \"{{ command }}\",\n \"env\": \"{{ env }}\",\n \"volumeMounts\": [\n { \"name\": \"temp-volume\", \"mountPath\": \"/tmp\" }\n ],\n \"securityContext\": {\n \"runAsUser\": 1001,\n \"runAsNonRoot\": true,\n \"readOnlyRootFilesystem\": true,\n \"allowPrivilegeEscalation\": false,\n \"capabilities\": { \"drop\": [\"ALL\"] }\n }\n }\n ]\n }\n },\n \"backoffLimit\": \"{{ backoff_limit }}\",\n \"ttlSecondsAfterFinished\": \"{{ finished_job_ttl }}\"\n }\n },\n \"stream_output\": \"{{ stream_output }}\",\n \"cluster_config\": \"{{ cluster_config }}\",\n \"job_watch_timeout_seconds\": \"{{ job_watch_timeout_seconds }}\",\n \"pod_watch_timeout_seconds\": \"{{ pod_watch_timeout_seconds }}\"\n }\n}\n"Marvin
12/10/2025, 8:39 AMThang Kieu
12/10/2025, 8:44 AM{
"variables": {
"type": "object",
"properties": {
"env": {
"type": "object",
"title": "Environment Variables",
"description": "Environment variables to set when starting a flow run.",
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Name",
"description": "Name given to infrastructure created by a worker."
},
"image": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Image",
"default": "<http://registry.sgts.gitlab-dedicated.com/wog/gvt/aibots/aibots/aibots-apps/orchestrator/prefect:3.6.5|registry.sgts.gitlab-dedicated.com/wog/gvt/aibots/aibots/aibots-apps/orchestrator/prefect:3.6.5>",
"examples": [
"<http://docker.io/prefecthq/prefect:3-latest|docker.io/prefecthq/prefect:3-latest>"
],
"description": "The image reference of a container image to use for created jobs. If not set, the latest Prefect image will be used."
},
"labels": {
"type": "object",
"title": "Labels",
"description": "Labels applied to infrastructure created by a worker.",
"additionalProperties": {
"type": "string"
}
},
"command": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Command",
"description": "The command to use when starting a flow run. In most cases, this should be left blank and the command will be automatically generated by the worker."
},
"namespace": {
"type": "string",
"title": "Namespace",
"default": "default",
"description": "The Kubernetes namespace to create jobs within."
},
"backoff_limit": {
"type": "integer",
"title": "Backoff Limit",
"default": 0,
"minimum": 0,
"description": "The number of times Kubernetes will retry a job after pod eviction. If set to 0, Prefect will reschedule the flow run when the pod is evicted."
},
"stream_output": {
"type": "boolean",
"title": "Stream Output",
"default": true,
"description": "If set, output will be streamed from the job to local standard output."
},
"cluster_config": {
"anyOf": [
{
"$ref": "#/definitions/KubernetesClusterConfig"
},
{
"type": "null"
}
],
"description": "The Kubernetes cluster config to use for job creation."
},
"finished_job_ttl": {
"type": "integer",
"default": 60,
"title": "Finished Job TTL",
"description": "The number of seconds to retain jobs after completion. If set, finished jobs will be cleaned up by Kubernetes after the given delay. If not set, jobs will be retained indefinitely."
},
"image_pull_policy": {
"enum": [
"IfNotPresent",
"Always",
"Never"
],
"type": "string",
"title": "Image Pull Policy",
"default": "IfNotPresent",
"description": "The Kubernetes image pull policy to use for job containers."
},
"service_account_name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Service Account Name",
"description": "The Kubernetes service account to use for job creation."
},
"job_watch_timeout_seconds": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Job Watch Timeout Seconds",
"description": "Number of seconds to wait for each event emitted by a job before timing out. If not set, the worker will wait for each event indefinitely."
},
"pod_watch_timeout_seconds": {
"type": "integer",
"title": "Pod Watch Timeout Seconds",
"default": 60,
"description": "Number of seconds to watch for pod creation before timing out."
}
},
"definitions": {
"KubernetesClusterConfig": {
"type": "object",
"title": "KubernetesClusterConfig",
"required": [
"config",
"context_name"
],
"properties": {
"config": {
"type": "object",
"title": "Config",
"description": "The entire contents of a kubectl config file.",
"additionalProperties": true
},
"context_name": {
"type": "string",
"title": "Context Name",
"description": "The name of the kubectl context to use."
}
}
}
}
},
"job_configuration": {
"env": "{{ env }}",
"name": "{{ name }}",
"labels": "{{ labels }}",
"command": "{{ command }}",
"namespace": "{{ namespace }}",
"job_manifest": {
"apiVersion": "batch/v1",
"kind": "Job",
"metadata": {
"labels": "{{ labels }}",
"namespace": "{{ namespace }}",
"generateName": "{{ name }}-"
},
"spec": {
"template": {
"spec": {
"parallelism": 1,
"completions": 1,
"restartPolicy": "Never",
"serviceAccountName": "{{ service_account_name }}",
"imagePullSecrets": [
{
"name": "gitlab-registry-secrets"
}
],
"securityContext": {
"runAsUser": 1001,
"runAsNonRoot": true,
"fsGroup": 1001,
"seccompProfile": {
"type": "RuntimeDefault"
}
},
"volumes": [
{
"name": "temp-volume",
"emptyDir": {
"sizeLimit": "5Gi"
}
}
],
"containers": [
{
"name": "prefect-job",
"image": "{{ image }}",
"imagePullPolicy": "{{ image_pull_policy }}",
"args": "{{ command }}",
"env": "{{ env }}",
"volumeMounts": [
{
"name": "temp-volume",
"mountPath": "/tmp"
}
],
"securityContext": {
"runAsUser": 1001,
"runAsNonRoot": true,
"readOnlyRootFilesystem": true,
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
}
}
}
]
}
},
"backoffLimit": "{{ backoff_limit }}",
"ttlSecondsAfterFinished": "{{ finished_job_ttl }}"
}
},
"stream_output": "{{ stream_output }}",
"cluster_config": "{{ cluster_config }}",
"job_watch_timeout_seconds": "{{ job_watch_timeout_seconds }}",
"pod_watch_timeout_seconds": "{{ pod_watch_timeout_seconds }}"
}
}Marvin
12/10/2025, 8:44 AM