Repository: bpg/terraform-provider-proxmox

Issue:
SDN Add auto_apply to SDN-related resources
**Is your feature request related to a problem? Please describe.**
Currently, if I use a single resource (VNET) I have to use two `proxmox_virtual_environment_sdn_applier` resources (applier and finalizer).

**Describe the solution you'd like**
As applying takes long time, I would prefer skip using those and just "auto-apply" during the creation and destruction.

**Describe alternatives you've considered**
Using `proxmox_virtual_environment_sdn_applier`  which is costly.

**Additional context**
```terraform
resource "proxmox_virtual_environment_sdn_vnet" "vnet" {
  id            = format("vnet%s", terraform_data.vlan.output)
  zone          = local.zone

  isolate_ports = false
  tag           = tonumber(terraform_data.vlan.output)
  vlan_aware    = true

  depends_on = [
    proxmox_virtual_environment_sdn_applier.vnet_finalizer,
  ]
}

resource "proxmox_virtual_environment_sdn_applier" "vnet_finalizer" {
}

resource "proxmox_virtual_environment_sdn_applier" "vnet_applier" {
  depends_on = [
    proxmox_virtual_environment_sdn_vnet.vnet,
  ]

  lifecycle {
    replace_triggered_by = [
      proxmox_virtual_environment_sdn_vnet.vnet,
    ]
  }
}
```


Hints from issue comments:
@bpg: Hey @maciej-lech 👋🏼 

Would you mind elaborating a bit on  
> ...and just "auto-apply" during the creation and destruction

What behavior are you looking for? Proxmox requires calling "apply" to activate the updated configuration. If you don’t want this to be part of the Terraform flow, you could remove the applier/finalizer.  
Or do you mean applying the changes asynchronously in the background, after the provider finishes with the resource and exits? That would go against Terraform best practices and make resource management much more complicated, so it’s not something I’d want to introduce in the provider.@maciej-lech: Hey @bpg!

> Or do you mean applying the changes asynchronously in the background, after the provider finishes with the resource and exits? That would go against Terraform best practices and make resource management much more complicated, so it’s not something I’d want to introduce in the provider.

Yes. but it could be done in synchronous way. If e.g. `auto_apply=true` (by default is false), in CREATE/UPDATE/DELETE calls we could apply the changes at the end and wait for applying to finish.

If I have only one SDN resource, and I use finalizer/applier scheme, I have two apply calls which are time-expensive.

@maciej-lech: Can I somehow only apply once, after resource is created/updated/deleted?@bpg: > ... I have two apply calls which are time-expensive.  

Okay, now I get your point :)

I don't see any significant delays during apply in my env, especially at creation time for the apply triggered by the finalizer when no new resources are created yet:

> ```
> Plan: 4 to add, 0 to change, 0 to destroy.
> proxmox_virtual_environment_sdn_applier.finalizer: Creating...
> proxmox_virtual_environment_sdn_applier.finalizer: Creation complete after 2s [id=1759664693920]
> proxmox_virtual_environment_sdn_zone_vlan.zone_x: Creating...
> proxmox_virtual_environment_sdn_zone_vlan.zone_x: Creation complete after 0s [id=zoneX]
> proxmox_virtual_environment_sdn_vnet.vnet_x: Creating...
> proxmox_virtual_environment_sdn_vnet.vnet_x: Creation complete after 0s [id=vnetX1]
> proxmox_virtual_environment_sdn_applier.applier: Creating...
> proxmox_virtual_environment_sdn_applier.applier: Creation complete after 2s [id=1759664696105]
> ```

Though I think the apply calls can definitely be optimized: the apply in "finalizer" can be skipped during creation, and the apply in "applier" can be skipped during deletion:

> ```
> Plan: 0 to add, 0 to change, 4 to destroy.
> proxmox_virtual_environment_sdn_applier.applier: Destroying... [id=1759664696105]
> proxmox_virtual_environment_sdn_applier.applier: Destruction complete after 2s
> proxmox_virtual_environment_sdn_vnet.vnet_x: Destroying... [id=vnetX1]
> proxmox_virtual_environment_sdn_vnet.vnet_x: Destruction complete after 0s
> proxmox_virtual_environment_sdn_zone_vlan.zone_x: Destroying... [id=zoneX]
> proxmox_virtual_environment_sdn_zone_vlan.zone_x: Destruction complete after 0s
> proxmox_virtual_environment_sdn_applier.finalizer: Destroying... [id=1759664693920]
> proxmox_virtual_environment_sdn_applier.finalizer: Destruction complete after 2s
> ```@maciej-lech: > I don't see any significant delays during apply in my env, especially at creation time for the apply triggered by the finalizer when no new resources are created yet:

Depends on the resources, cluster size and amount of ifaces. It basically reloads the whole networking with `ifupdown2` in a synchronous way - so one node at a time.

> Though I think the apply calls can definitely be optimized: the apply in "finalizer" can be skipped during creation, and the apply in "applier" can be skipped during deletion:

But it is not something that is currently supported, right? Or I missed something?

Another way would be to check if there are pending changes, but I believe right now it would be two API calls - one for zones and one for vnets - although those should be cheap. If I am not mistaken, there is no a single call that can do it.
This additional check could be even triggered by some `proxmox_virtual_environment_sdn_applier` flag, disabled by default. IMO this is even nicer way than the proposed by me in the first post :)@maciej-lech: > but I believe right now it would be two API calls

Or 3 calls. Not sure if fetching pending changes for vlans includes pending changes for subnets. From the API hierarchy, it should be like that, however this is Proxmox API...@maciej-lech: @bpg I have two proposals (backward-compatible), different than the one I described above.

1. With `on_create`, `on_update`, `on_destroy` flags (all `true` by default)

These flags indicate when the configuration is applied.

```tf
resource "proxmox_virtual_environment_sdn_zone_simple" "zone" {
  id    = "simple"
  nodes = ["pve"]

  depends_on = [
    proxmox_virtual_environment_sdn_applier.destroy_applier
  ]
}

resource "proxmox_virtual_environment_sdn_applier" "create_applier" {
  on_create  = true
  on_update  = false
  on_destroy = false

  lifecycle {
    replace_triggered_by = [
      proxmox_virtual_environment_sdn_zone_simple.zone,
    ]
  }

  depends_on = [
    proxmox_virtual_environment_sdn_zone_simple.zone
  ]
}

resource "proxmox_virtual_environment_sdn_applier" "destroy_applier" {
  on_create  = false
  on_update  = false
  on_destroy = true
}
```

This solves problem with multiple `apply` actions, including the action taken by the `replace_triggered_by`. It is a little bit verbose, but it is fully backward compatible, so flags do not need to be configured. With all set to `true` the behavior is exactly the same as now.

Create:

```
+----------------------------+
| destroy_applier (no apply) |
+-------------+--------------+
              |
              v
        +-----+-----+
        |   zone    |
        +-----+-----+
              |
              v
+----------------------------+
|  create_applier (apply)    |
+----------------------------+
```

Destroy:

```
+------------------------------+
| create_applier (no apply)    |
+---------------+--------------+
                |
                v
          +-----+-----+
          |   zone    |
          +-----+-----+
                |
                v
+------------------------------+
| destroy_applier (apply)      |
+------------------------------+
```

Update in-place:

```
+------------------+
|   zone update    |
+---------+--------+
          |
          v
+-------------------------------+
| create_applier destroy        |
|         (no apply)            |
+--------------+----------------+
               |
               v
+-------------------------------+
| create_applier create         |
|           (apply)             |
+-------------------------------+
```


2. With mutually-exclusive `lock`/`unlock` flags

Proxmox SDN API supports global locking of the configuration [1]. This could be useful to implement, despite the problem described in this ticket.

```tf
resource "proxmox_virtual_environment_sdn_zone_simple" "zone" {
  id    = "simple"
  nodes = ["pve"]

  depends_on = [
    proxmox_virtual_environment_sdn_applier.lock_applier
  ]
}

resource "proxmox_virtual_environment_sdn_applier" "lock_applier" {
  lock = true
}

resource "proxmox_virtual_environment_sdn_applier" "unlock_applier" {
  unlock = true

  lifecycle {
    replace_triggered_by = [
      proxmox_virtual_environment_sdn_zone_simple.zone,
    ]
  }

  depends_on = [
    proxmox_virtual_environment_sdn_zone_simple.zone
  ]
}
```

This also solves problem with multiple `apply` actions. Applying only take place before unlock, and unlock is triggered in `destroy` if `lock=true` and in `create` when `unlock=true`. The one downside of this approach is that in case of in-place updates, it will not lock before the update take place. It still applies the changes, but the lock is not there during the in-place updates (see 3rd diagram below).

Create:

```
+------------------------+
| lock_applier (lock)    |
+-----------+------------+
            |
            v
      +-----+-----+
      |   zone    |
      +-----+-----+
            |
            v
+-------------------------------+
| unlock_applier (apply+unlock) |
+-------------------------------+
```

Destroy:

```
+-------------------------+
| unlock_applier (lock)   |
+-----------+-------------+
            |
            v
      +-----+-----+
      |   zone    |
      +-----+-----+
            |
            v
+-------------------------------+
| lock_applier (apply+unlock)   |
+-------------------------------+
```

Update in-place:

```
+------------------+
|   zone update    |
+---------+--------+
          |
          v
+--------------------------------------+
| unlock_applier destroy (lock)        |
+----------------+---------------------+
                 |
                 v
+---------------------------------------+
| unlock_applier destroy (apply+unlock) |
+---------------------------------------+
```

---

Both solutions will solve my problem. The first one is probably more simple to understand. On the other hand, locking is also something nice to have.

Please do the review and let me know what do you think :)

---

[1]: https://pve.proxmox.com/pve-docs/api-viewer/#/cluster/sdn/lock

BR/ Maciek
@bpg: Thanks for putting this together @maciej-lech  
I'm leaning toward the first approach. Locking seems more risky, since there is a chance to leave the system in a locked state if the applier is not called for any reason.

Solution patch:
diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml
new file mode 100644
index 00000000..dcfdb76a
--- /dev/null
+++ b/.github/workflows/project-automation.yml
@@ -0,0 +1,327 @@
+name: Project Automation
+
+on:
+  issues:
+    types: [opened, labeled, closed, reopened]
+  pull_request:
+    types: [opened, closed]
+
+env:
+  PROJECT_NUMBER: 4
+  PROJECT_OWNER: bpg
+
+jobs:
+  add-to-project:
+    name: Add issue/PR to project
+    runs-on: ubuntu-latest
+    if: github.event.action == 'opened'
+    steps:
+      - name: Add to project
+        uses: actions/add-to-project@v1.0.2
+        with:
+          project-url: https://github.com/users/${{ env.PROJECT_OWNER }}/projects/${{ env.PROJECT_NUMBER }}
+          github-token: ${{ secrets.PROJECT_TOKEN }}
+
+  sync-priority-label-to-field:
+    name: Sync priority label to Priority field
+    runs-on: ubuntu-latest
+    if: github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority:')
+    steps:
+      - name: Get priority from label
+        id: priority
+        run: |
+          LABEL="${{ github.event.label.name }}"
+          PRIORITY="${LABEL#priority:}"
+          echo "value=$PRIORITY" >> $GITHUB_OUTPUT
+          echo "Priority: $PRIORITY"
+
+      - name: Remove other priority labels
+        uses: actions/github-script@v7
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          script: |
+            const newLabel = '${{ github.event.label.name }}';
+            const issueNumber = context.payload.issue.number;
+            const allPriorityLabels = ['priority:P0', 'priority:P1', 'priority:P2', 'priority:P3'];
+            
+            for (const label of allPriorityLabels) {
+              if (label !== newLabel) {
+                try {
+                  await github.rest.issues.removeLabel({
+                    owner: context.repo.owner,
+                    repo: context.repo.repo,
+                    issue_number: issueNumber,
+                    name: label
+                  });
+                  console.log(`Removed ${label}`);
+                } catch (e) {
+                  // Label might not exist on issue, that's ok
+                }
+              }
+            }
+
+      - name: Update Priority field in project
+        uses: actions/github-script@v7
+        with:
+          github-token: ${{ secrets.PROJECT_TOKEN }}
+          script: |
+            const priority = '${{ steps.priority.outputs.value }}';
+            const issueNodeId = context.payload.issue.node_id;
+            
+            // Get project and field info
+            const projectQuery = `
+              query($owner: String!, $number: Int!) {
+                user(login: $owner) {
+                  projectV2(number: $number) {
+                    id
+                    field(name: "Priority") {
+                      ... on ProjectV2SingleSelectField {
+                        id
+                        options { id name }
+                      }
+                    }
+                  }
+                }
+              }
+            `;
+            
+            const projectResult = await github.graphql(projectQuery, {
+              owner: '${{ env.PROJECT_OWNER }}',
+              number: ${{ env.PROJECT_NUMBER }}
+            });
+            
+            const project = projectResult.user.projectV2;
+            const priorityField = project.field;
+            
+            if (!priorityField) {
+              console.log('Priority field not found');
+              return;
+            }
+            
+            const option = priorityField.options.find(o => o.name === priority);
+            if (!option) {
+              console.log(`Priority option ${priority} not found`);
+              return;
+            }
+            
+            // Add issue to project (or get existing item ID)
+            const addMutation = `
+              mutation($projectId: ID!, $contentId: ID!) {
+                addProjectV2ItemById(input: {
+                  projectId: $projectId
+                  contentId: $contentId
+                }) {
+                  item { id }
+                }
+              }
+            `;
+            
+            let itemId;
+            try {
+              const addResult = await github.graphql(addMutation, {
+                projectId: project.id,
+                contentId: issueNodeId
+              });
+              itemId = addResult.addProjectV2ItemById.item.id;
+              console.log(`Added/found issue in project, item ID: ${itemId}`);
+            } catch (e) {
+              console.log(`Error adding to project: ${e.message}`);
+              return;
+            }
+            
+            // Update the Priority field
+            const mutation = `
+              mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
+                updateProjectV2ItemFieldValue(input: {
+                  projectId: $projectId
+                  itemId: $itemId
+                  fieldId: $fieldId
+                  value: { singleSelectOptionId: $optionId }
+                }) {
+                  projectV2Item { id }
+                }
+              }
+            `;
+            
+            await github.graphql(mutation, {
+              projectId: project.id,
+              itemId: itemId,
+              fieldId: priorityField.id,
+              optionId: option.id
+            });
+            
+            console.log(`Updated Priority to ${priority}`);
+
+  move-to-column-on-label:
+    name: Move to column based on label
+    runs-on: ubuntu-latest
+    if: github.event.action == 'labeled'
+    steps:
+      - name: Determine target column
+        id: column
+        run: |
+          LABEL="${{ github.event.label.name }}"
+          case "$LABEL" in
+            "priority:P0") echo "column=P0" >> $GITHUB_OUTPUT ;;
+            "priority:P1") echo "column=P1" >> $GITHUB_OUTPUT ;;
+            "priority:P2") echo "column=Triage" >> $GITHUB_OUTPUT ;;
+            "priority:P3") echo "column=Backlog" >> $GITHUB_OUTPUT ;;
+            "lifecycle:needs-info") echo "column=Needs Info" >> $GITHUB_OUTPUT ;;
+            "status:in-progress") echo "column=In Progress" >> $GITHUB_OUTPUT ;;
+            "status:blocked") echo "column=Blocked" >> $GITHUB_OUTPUT ;;
+            *) echo "column=" >> $GITHUB_OUTPUT ;;
+          esac
+
+      - name: Add to project and move to column
+        if: steps.column.outputs.column != ''
+        uses: actions/github-script@v7
+        with:
+          github-token: ${{ secrets.PROJECT_TOKEN }}
+          script: |
+            const targetColumn = '${{ steps.column.outputs.column }}';
+            const issueNodeId = context.payload.issue.node_id;
+            
+            // Get project info
+            const projectQuery = `
+              query($owner: String!, $number: Int!) {
+                user(login: $owner) {
+                  projectV2(number: $number) {
+                    id
+                    field(name: "Status") {
+                      ... on ProjectV2SingleSelectField {
+                        id
+                        options { id name }
+                      }
+                    }
+                  }
+                }
+              }
+            `;
+            
+            const projectResult = await github.graphql(projectQuery, {
+              owner: '${{ env.PROJECT_OWNER }}',
+              number: ${{ env.PROJECT_NUMBER }}
+            });
+            
+            const project = projectResult.user.projectV2;
+            const statusField = project.field;
+            const option = statusField.options.find(o => o.name === targetColumn);
+            
+            if (!option) {
+              console.log(`Column ${targetColumn} not found`);
+              return;
+            }
+            
+            // First, try to add the issue to the project (will return existing item if already added)
+            const addMutation = `
+              mutation($projectId: ID!, $contentId: ID!) {
+                addProjectV2ItemById(input: {
+                  projectId: $projectId
+                  contentId: $contentId
+                }) {
+                  item { id }
+                }
+              }
+            `;
+            
+            let itemId;
+            try {
+              const addResult = await github.graphql(addMutation, {
+                projectId: project.id,
+                contentId: issueNodeId
+              });
+              itemId = addResult.addProjectV2ItemById.item.id;
+              console.log(`Added/found issue in project, item ID: ${itemId}`);
+            } catch (e) {
+              console.log(`Error adding to project: ${e.message}`);
+              return;
+            }
+            
+            // Update Status field
+            const updateMutation = `
+              mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
+                updateProjectV2ItemFieldValue(input: {
+                  projectId: $projectId
+                  itemId: $itemId
+                  fieldId: $fieldId
+                  value: { singleSelectOptionId: $optionId }
+                }) {
+                  projectV2Item { id }
+                }
+              }
+            `;
+            
+            await github.graphql(updateMutation, {
+              projectId: project.id,
+              itemId: itemId,
+              fieldId: statusField.id,
+              optionId: option.id
+            });
+            
+            console.log(`Moved to ${targetColumn}`);
+
+  pr-link-to-in-progress:
+    name: Move linked issue to In Progress when PR opened
+    runs-on: ubuntu-latest
+    if: github.event_name == 'pull_request' && github.event.action == 'opened'
+    steps:
+      - name: Add in-progress label to linked issues
+        uses: actions/github-script@v7
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          script: |
+            const prBody = context.payload.pull_request.body || '';
+            const prTitle = context.payload.pull_request.title || '';
+            
+            const issuePattern = /(?:fixes|closes|resolves|ref|references?)?\s*#(\d+)/gi;
+            const allText = `${prTitle} ${prBody}`;
+            const matches = [...allText.matchAll(issuePattern)];
+            const uniqueIssues = [...new Set(matches.map(m => parseInt(m[1])))];
+            
+            for (const issueNumber of uniqueIssues) {
+              console.log(`PR references issue #${issueNumber}, adding status:in-progress label`);
+              
+              try {
+                await github.rest.issues.addLabels({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: issueNumber,
+                  labels: ['status:in-progress']
+                });
+              } catch (e) {
+                console.log(`Could not add label to issue #${issueNumber}: ${e.message}`);
+              }
+            }
+
+  pr-merged-to-review:
+    name: Move linked issue to Review when PR merged
+    runs-on: ubuntu-latest
+    if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true
+    steps:
+      - name: Update linked issues
+        uses: actions/github-script@v7
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          script: |
+            const prBody = context.payload.pull_request.body || '';
+            const prTitle = context.payload.pull_request.title || '';
+            
+            const issuePattern = /(?:fixes|closes|resolves|ref|references?)?\s*#(\d+)/gi;
+            const allText = `${prTitle} ${prBody}`;
+            const matches = [...allText.matchAll(issuePattern)];
+            const uniqueIssues = [...new Set(matches.map(m => parseInt(m[1])))];
+            
+            for (const issueNumber of uniqueIssues) {
+              console.log(`PR merged, removing status:in-progress from #${issueNumber}`);
+              
+              try {
+                await github.rest.issues.removeLabel({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: issueNumber,
+                  name: 'status:in-progress'
+                });
+              } catch (e) {
+                // Label might not exist
+              }
+            }
diff --git a/docs/resources/virtual_environment_container.md b/docs/resources/virtual_environment_container.md
index af3302cf..12ef531e 100644
--- a/docs/resources/virtual_environment_container.md
+++ b/docs/resources/virtual_environment_container.md
@@ -141,6 +141,7 @@ output "ubuntu_container_public_key" {
         to `4`). When set to 0 a directory or zfs/btrfs subvolume will be created.
         Requires `datastore_id` to be set.
     - `mount_options` (Optional) List of extra mount options.
+- `environment_variables` - (Optional) A map of runtime environment variables for the container init process.
 - `initialization` - (Optional) The initialization configuration.
     - `dns` - (Optional) The DNS configuration.
         - `domain` - (Optional) The DNS search domain.
diff --git a/docs/resources/virtual_environment_oci_image.md b/docs/resources/virtual_environment_oci_image.md
new file mode 100644
index 00000000..9f52b23e
--- /dev/null
+++ b/docs/resources/virtual_environment_oci_image.md
@@ -0,0 +1,59 @@
+---
+layout: page
+title: proxmox_virtual_environment_oci_image
+parent: Resources
+subcategory: Virtual Environment
+description: |-
+  Manages OCI images pulled from OCI registries using PVE oci-registry-pull API. Pulls OCI container images and stores them as tar files in Proxmox VE datastores.
+---
+
+# Resource: proxmox_virtual_environment_oci_image
+
+Manages OCI images pulled from OCI registries using PVE oci-registry-pull API. Pulls OCI container images and stores them as tar files in Proxmox VE datastores.
+
+## Example Usage
+
+```terraform
+resource "proxmox_virtual_environment_oci_image" "ubuntu_latest" {
+  node_name    = "pve"
+  datastore_id = "local"
+  reference    = "docker.io/library/ubuntu:latest"
+}
+
+resource "proxmox_virtual_environment_oci_image" "nginx" {
+  node_name    = "pve"
+  datastore_id = "local"
+  reference    = "docker.io/library/nginx:alpine"
+  file_name    = "custom_image_name.tar"
+}
+
+resource "proxmox_virtual_environment_oci_image" "debian" {
+  node_name           = "pve"
+  datastore_id        = "local"
+  reference           = "docker.io/library/debian:bookworm"
+  upload_timeout      = 900
+  overwrite           = false
+  overwrite_unmanaged = true
+}
+```
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `datastore_id` (String) The identifier for the target datastore.
+- `node_name` (String) The node name.
+- `reference` (String) The reference to the OCI image.
+
+### Optional
+
+- `file_name` (String) The file name for the pulled OCI image. If not provided, it will be generated automatically. The file will be stored as a .tar file.
+- `overwrite` (Boolean) By default `true`. If `true` and the OCI image size has changed in the datastore, it will be replaced. If `false`, there will be no check.
+- `overwrite_unmanaged` (Boolean) If `true` and an OCI image with the same name already exists in the datastore, it will be deleted and the new image will be pulled. If `false` and the image already exists, an error will be returned.
+- `upload_timeout` (Number) The OCI image pull timeout in seconds. Default is 600 (10min).
+
+### Read-Only
+
+- `id` (String) The unique identifier of this resource.
+- `size` (Number) The image size in PVE.
diff --git a/docs/resources/virtual_environment_sdn_applier.md b/docs/resources/virtual_environment_sdn_applier.md
index a8a190fe..6fbe8179 100644
--- a/docs/resources/virtual_environment_sdn_applier.md
+++ b/docs/resources/virtual_environment_sdn_applier.md
@@ -64,6 +64,11 @@ resource "proxmox_virtual_environment_sdn_applier" "finalizer" {
 <!-- schema generated by tfplugindocs -->
 ## Schema
 
+### Optional
+
+- `on_create` (Boolean) Whether to apply SDN configuration on resource creation. Defaults to true.
+- `on_destroy` (Boolean) Whether to apply SDN configuration on resource destruction. Defaults to true.
+
 ### Read-Only
 
 - `id` (String) Opaque identifier set to the Unix timestamp (milliseconds) when the apply was executed.
diff --git a/examples/resources/proxmox_virtual_environment_oci_image/resource.tf b/examples/resources/proxmox_virtual_environment_oci_image/resource.tf
new file mode 100644
index 00000000..6033a1fa
--- /dev/null
+++ b/examples/resources/proxmox_virtual_environment_oci_image/resource.tf
@@ -0,0 +1,21 @@
+resource "proxmox_virtual_environment_oci_image" "ubuntu_latest" {
+  node_name    = "pve"
+  datastore_id = "local"
+  reference    = "docker.io/library/ubuntu:latest"
+}
+
+resource "proxmox_virtual_environment_oci_image" "nginx" {
+  node_name    = "pve"
+  datastore_id = "local"
+  reference    = "docker.io/library/nginx:alpine"
+  file_name    = "custom_image_name.tar"
+}
+
+resource "proxmox_virtual_environment_oci_image" "debian" {
+  node_name           = "pve"
+  datastore_id        = "local"
+  reference           = "docker.io/library/debian:bookworm"
+  upload_timeout      = 900
+  overwrite           = false
+  overwrite_unmanaged = true
+}
diff --git a/fwprovider/cluster/sdn/applier/resource.go b/fwprovider/cluster/sdn/applier/resource.go
index 998e67c4..ff9b2550 100644
--- a/fwprovider/cluster/sdn/applier/resource.go
+++ b/fwprovider/cluster/sdn/applier/resource.go
@@ -16,6 +16,9 @@ import (
 	"github.com/bpg/terraform-provider-proxmox/proxmox/cluster/sdn/applier"
 	"github.com/hashicorp/terraform-plugin-framework/resource"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
 	"github.com/hashicorp/terraform-plugin-framework/types"
 )
 
@@ -26,7 +29,9 @@ var (
 
 type model struct {
 	// Opaque ID set timestamp at creation time.
-	ID types.String `tfsdk:"id"`
+	ID        types.String `tfsdk:"id"`
+	OnCreate  types.Bool   `tfsdk:"on_create"`
+	OnDestroy types.Bool   `tfsdk:"on_destroy"`
 }
 
 type Resource struct {
@@ -51,6 +56,24 @@ func (r *Resource) Schema(_ context.Context, _ resource.SchemaRequest, resp *res
 				Computed:    true,
 				Description: "Opaque identifier set to the Unix timestamp (milliseconds) when the apply was executed.",
 			},
+			"on_create": schema.BoolAttribute{
+				Optional: true,
+				Computed: true,
+				Default:  booldefault.StaticBool(true),
+				PlanModifiers: []planmodifier.Bool{
+					boolplanmodifier.RequiresReplace(),
+				},
+				Description: "Whether to apply SDN configuration on resource creation. Defaults to true.",
+			},
+			"on_destroy": schema.BoolAttribute{
+				Optional: true,
+				Computed: true,
+				Default:  booldefault.StaticBool(true),
+				PlanModifiers: []planmodifier.Bool{
+					boolplanmodifier.RequiresReplace(),
+				},
+				Description: "Whether to apply SDN configuration on resource destruction. Defaults to true.",
+			},
 		},
 	}
 }
@@ -73,16 +96,23 @@ func (r *Resource) Configure(_ context.Context, req resource.ConfigureRequest, r
 	r.client = cfg.Client.Cluster().SDNApplier()
 }
 
-func (r *Resource) Create(ctx context.Context, _ resource.CreateRequest, resp *resource.CreateResponse) {
-	if err := r.client.ApplyConfig(ctx); err != nil {
-		resp.Diagnostics.AddError("Unable to Apply SDN Configuration", err.Error())
+func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var plan model
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+
+	if resp.Diagnostics.HasError() {
 		return
 	}
 
-	state := &model{
-		ID: types.StringValue(strconv.FormatInt(time.Now().UTC().UnixMilli(), 10)),
+	if plan.OnCreate.ValueBool() {
+		if err := r.client.ApplyConfig(ctx); err != nil {
+			resp.Diagnostics.AddError("Unable to Apply SDN Configuration", err.Error())
+			return
+		}
 	}
-	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+
+	plan.ID = types.StringValue(strconv.FormatInt(time.Now().UTC().UnixMilli(), 10))
+	resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
 }
 
 func (r *Resource) Read(_ context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) {
@@ -90,13 +120,6 @@ func (r *Resource) Read(_ context.Context, _ resource.ReadRequest, _ *resource.R
 }
 
 func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
-	// We expect replacements only. But if someone does in-place Update,
-	// we just re-run apply for safety and bump the ID timestamp.
-	if err := r.client.ApplyConfig(ctx); err != nil {
-		resp.Diagnostics.AddError("Unable to Re-Apply SDN Configuration", err.Error())
-		return
-	}
-
 	var plan model
 	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
 
@@ -104,13 +127,29 @@ func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp
 		return
 	}
 
+	// We expect replacements only. But if someone does in-place Update,
+	// we just re-run apply for safety and bump the ID timestamp.
+	if err := r.client.ApplyConfig(ctx); err != nil {
+		resp.Diagnostics.AddError("Unable to Re-Apply SDN Configuration", err.Error())
+		return
+	}
+
 	plan.ID = types.StringValue(strconv.FormatInt(time.Now().UTC().UnixMilli(), 10))
 	resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
 }
 
-func (r *Resource) Delete(ctx context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
-	if err := r.client.ApplyConfig(ctx); err != nil {
-		resp.Diagnostics.AddError("Unable to Re-Apply SDN Configuration", err.Error())
+func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+	var state model
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
+	if resp.Diagnostics.HasError() {
 		return
 	}
+
+	if state.OnDestroy.ValueBool() {
+		if err := r.client.ApplyConfig(ctx); err != nil {
+			resp.Diagnostics.AddError("Unable to Apply SDN Configuration", err.Error())
+			return
+		}
+	}
 }
diff --git a/fwprovider/nodes/resource_download_file.go b/fwprovider/nodes/resource_download_file.go
index 855265d2..5f4b2f1b 100644
--- a/fwprovider/nodes/resource_download_file.go
+++ b/fwprovider/nodes/resource_download_file.go
@@ -47,6 +47,7 @@ var (
 
 type sizeRequiresReplaceModifier struct{}
 
+//nolint:dupl
 func (r sizeRequiresReplaceModifier) PlanModifyInt64(
 	ctx context.Context,
 	req planmodifier.Int64Request,
@@ -550,6 +551,8 @@ func (r *downloadFileResource) Update(
 }
 
 // Delete removes file resource.
+//
+//nolint:dupl
 func (r *downloadFileResource) Delete(
 	ctx context.Context,
 	req resource.DeleteRequest,
diff --git a/fwprovider/nodes/resource_oci_image.go b/fwprovider/nodes/resource_oci_image.go
new file mode 100644
index 00000000..96405396
--- /dev/null
+++ b/fwprovider/nodes/resource_oci_image.go
@@ -0,0 +1,472 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package nodes
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+	"github.com/hashicorp/terraform-plugin-framework/resource"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+
+	"github.com/bpg/terraform-provider-proxmox/fwprovider/attribute"
+	"github.com/bpg/terraform-provider-proxmox/fwprovider/config"
+	"github.com/bpg/terraform-provider-proxmox/proxmox"
+	"github.com/bpg/terraform-provider-proxmox/proxmox/api"
+	"github.com/bpg/terraform-provider-proxmox/proxmox/nodes/storage"
+)
+
+var (
+	_              resource.Resource              = &ociImageResource{}
+	_              resource.ResourceWithConfigure = &ociImageResource{}
+	referenceRegex                                = regexp.MustCompile(
+		`^(?:(?:[a-zA-Z\d]|[a-zA-Z\d][a-zA-Z\d-]*[a-zA-Z\d])(?:\.(?:[a-zA-Z\d]|[a-zA-Z\d][a-zA-Z\d-]*[a-zA-Z\d]))*` +
+			`(?::\d+)?/)?[a-z\d]+(?:(?:[._]|__|[-]*)[a-z\d]+)*(?:/[a-z\d]+(?:(?:[._]|__|[-]*)[a-z\d]+)*)*:\w[\w.-]{0,127}$`,
+	)
+)
+
+const ociSizeRequiresReplaceDescription = "Triggers resource force replacement if `size` in state does not match remote value."
+
+type ociSizeRequiresReplaceModifier struct{}
+
+//nolint:dupl
+func (r ociSizeRequiresReplaceModifier) PlanModifyInt64(
+	ctx context.Context,
+	req planmodifier.Int64Request,
+	resp *planmodifier.Int64Response,
+) {
+	// Do not replace on resource creation.
+	if req.State.Raw.IsNull() {
+		return
+	}
+
+	// Do not replace on resource destroy.
+	if req.Plan.Raw.IsNull() {
+		return
+	}
+
+	var plan, state ociImageModel
+
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+
+	originalStateSizeBytes, diags := req.Private.GetKey(ctx, "original_state_size")
+
+	resp.Diagnostics.Append(diags...)
+
+	if originalStateSizeBytes != nil {
+		originalStateSize, err := strconv.ParseInt(string(originalStateSizeBytes), 10, 64)
+		if err != nil {
+			resp.Diagnostics.AddError(
+				"Unable to convert original state OCI image size to int64",
+				"Unexpected error in parsing string to int64, key original_state_size. "+
+					"Please retry the operation or report this issue to the provider developers.\n\n"+
+					"Error: "+err.Error(),
+			)
+
+			return
+		}
+
+		if state.Size.ValueInt64() != originalStateSize && plan.Overwrite.ValueBool() {
+			resp.RequiresReplace = true
+			resp.PlanValue = types.Int64Value(originalStateSize)
+
+			resp.Diagnostics.AddWarning(
+				"The OCI image size in datastore has changed outside of terraform.",
+				fmt.Sprintf(
+					"Previous size: %d saved in state does not match current size from datastore: %d. "+
+						"You can disable this behaviour by using overwrite=false",
+					originalStateSize,
+					state.Size.ValueInt64(),
+				),
+			)
+
+			return
+		}
+	}
+}
+
+func (r ociSizeRequiresReplaceModifier) Description(_ context.Context) string {
+	return ociSizeRequiresReplaceDescription
+}
+
+func (r ociSizeRequiresReplaceModifier) MarkdownDescription(_ context.Context) string {
+	return ociSizeRequiresReplaceDescription
+}
+
+type ociImageModel struct {
+	ID                 types.String `tfsdk:"id"`
+	FileName           types.String `tfsdk:"file_name"`
+	Storage            types.String `tfsdk:"datastore_id"`
+	Node               types.String `tfsdk:"node_name"`
+	Size               types.Int64  `tfsdk:"size"`
+	Reference          types.String `tfsdk:"reference"`
+	UploadTimeout      types.Int64  `tfsdk:"upload_timeout"`
+	Overwrite          types.Bool   `tfsdk:"overwrite"`
+	OverwriteUnmanaged types.Bool   `tfsdk:"overwrite_unmanaged"`
+}
+
+// NewOCIImageResource manages OCI images downloaded using Proxmox API.
+func NewOCIImageResource() resource.Resource {
+	return &ociImageResource{}
+}
+
+type ociImageResource struct {
+	client proxmox.Client
+}
+
+func (r *ociImageResource) Metadata(
+	_ context.Context,
+	req resource.MetadataRequest,
+	resp *resource.MetadataResponse,
+) {
+	resp.TypeName = req.ProviderTypeName + "_oci_image"
+}
+
+// Schema defines the schema for the resource.
+func (r *ociImageResource) Schema(
+	_ context.Context,
+	_ resource.SchemaRequest,
+	resp *resource.SchemaResponse,
+) {
+	resp.Schema = schema.Schema{
+		Description: "Manages OCI images pulled from OCI registries using PVE oci-registry-pull API. ",
+		MarkdownDescription: "Manages OCI images pulled from OCI registries using PVE oci-registry-pull API. " +
+			"Pulls OCI container images and stores them as tar files in Proxmox VE datastores.",
+		Attributes: map[string]schema.Attribute{
+			"id": attribute.ResourceID(),
+			"file_name": schema.StringAttribute{
+				Description: "The file name for the pulled OCI image. If not provided, " +
+					"it will be generated automatically. The file will be stored as a .tar file.",
+				Computed: true,
+				Required: false,
+				Optional: true,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.RequiresReplace(),
+					stringplanmodifier.UseStateForUnknown(),
+				},
+				Validators: []validator.String{
+					stringvalidator.RegexMatches(
+						regexp.MustCompile(`\.tar$`),
+						"file name must end with .tar",
+					),
+				},
+			},
+			"datastore_id": schema.StringAttribute{
+				Description: "The identifier for the target datastore.",
+				Required:    true,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.RequiresReplace(),
+				},
+			},
+			"node_name": schema.StringAttribute{
+				Description: "The node name.",
+				Required:    true,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.RequiresReplace(),
+				},
+			},
+			"size": schema.Int64Attribute{
+				Description: "The image size in PVE.",
+				Optional:    false,
+				Required:    false,
+				Computed:    true,
+				PlanModifiers: []planmodifier.Int64{
+					int64planmodifier.UseStateForUnknown(),
+					ociSizeRequiresReplaceModifier{},
+				},
+			},
+			"upload_timeout": schema.Int64Attribute{
+				Description: "The OCI image pull timeout in seconds. Default is 600 (10min).",
+				Optional:    true,
+				Computed:    true,
+				Default:     int64default.StaticInt64(600),
+			},
+			"reference": schema.StringAttribute{
+				Description: "The reference to the OCI image.",
+				Required:    true,
+				Validators: []validator.String{
+					stringvalidator.RegexMatches(referenceRegex, "must match OCI image reference regex `"+referenceRegex.String()+"`"),
+				},
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.RequiresReplace(),
+				},
+			},
+			"overwrite": schema.BoolAttribute{
+				Description: "By default `true`. If `true` and the OCI image size has changed in the datastore, " +
+					"it will be replaced. If `false`, there will be no check.",
+				Optional: true,
+				Computed: true,
+				Default:  booldefault.StaticBool(true),
+			},
+			"overwrite_unmanaged": schema.BoolAttribute{
+				Description: "If `true` and an OCI image with the same name already exists in the datastore, " +
+					"it will be deleted and the new image will be pulled. If `false` and the image already exists, " +
+					"an error will be returned.",
+				Optional: true,
+				Computed: true,
+				Default:  booldefault.StaticBool(false),
+			},
+		},
+	}
+}
+
+func (r *ociImageResource) Configure(
+	_ context.Context,
+	req resource.ConfigureRequest,
+	resp *resource.ConfigureResponse,
+) {
+	if req.ProviderData == nil {
+		return
+	}
+
+	cfg, ok := req.ProviderData.(config.Resource)
+
+	if !ok {
+		resp.Diagnostics.AddError(
+			"Unexpected Resource Configure Type",
+			fmt.Sprintf("Expected *proxmox.Client, got: %T", req.ProviderData),
+		)
+
+		return
+	}
+
+	r.client = cfg.Client
+}
+
+func (r *ociImageResource) Create(
+	ctx context.Context,
+	req resource.CreateRequest,
+	resp *resource.CreateResponse,
+) {
+	var plan ociImageModel
+
+	diags := req.Plan.Get(ctx, &plan)
+	resp.Diagnostics.Append(diags...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	timeout := time.Duration(plan.UploadTimeout.ValueInt64()) * time.Second
+
+	ctx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
+	// If filename is not provided, generate one from reference
+	if plan.FileName.IsUnknown() || plan.FileName.IsNull() {
+		// Generate filename from reference (e.g., "docker.io/library/ubuntu:latest" -> "ubuntu_latest.tar")
+		refParts := strings.Split(plan.Reference.ValueString(), "/")
+		lastPart := refParts[len(refParts)-1]
+		filename := strings.ReplaceAll(lastPart, ":", "_")
+
+		plan.FileName = types.StringValue(filename + ".tar")
+	}
+
+	nodesClient := r.client.Node(plan.Node.ValueString())
+
+	// Proxmox API expects filename without .tar extension
+	filenameWithoutTar := strings.TrimSuffix(plan.FileName.ValueString(), ".tar")
+
+	ociPullReq := storage.OCIRegistryPullRequestBody{
+		Storage:   plan.Storage.ValueStringPointer(),
+		FileName:  &filenameWithoutTar,
+		Reference: plan.Reference.ValueStringPointer(),
+	}
+
+	storageClient := nodesClient.Storage(plan.Storage.ValueString())
+
+	err := storageClient.DownloadOCIImageByReference(ctx, &ociPullReq)
+	if isErrFileAlreadyExists(err) && plan.OverwriteUnmanaged.ValueBool() {
+		// OCI images are stored as vztmpl content type in Proxmox
+		fileID := "vztmpl/" + plan.FileName.ValueString()
+
+		err = storageClient.DeleteDatastoreFile(ctx, fileID)
+		if err != nil && !errors.Is(err, api.ErrResourceDoesNotExist) {
+			resp.Diagnostics.AddError("Error deleting OCI image from datastore",
+				fmt.Sprintf("Could not delete OCI image '%s', unexpected error: %s", fileID, err.Error()),
+			)
+		}
+
+		err = storageClient.DownloadOCIImageByReference(ctx, &ociPullReq)
+	}
+
+	if err != nil {
+		if isErrFileAlreadyExists(err) {
+			resp.Diagnostics.AddError(
+				"File already exists in the datastore, it was created outside of Terraform "+
+					"or is managed by another resource.",
+				fmt.Sprintf("File already exists in the datastore: '%s', error: %s",
+					plan.FileName.ValueString(), err.Error(),
+				),
+			)
+		} else {
+			resp.Diagnostics.AddError(
+				"Error pulling OCI image from reference",
+				fmt.Sprintf("Could not pull OCI image '%s', unexpected error: %s",
+					plan.FileName.ValueString(), err.Error()),
+			)
+		}
+
+		return
+	}
+
+	// OCI images are stored as vztmpl content type in Proxmox
+	plan.ID = types.StringValue(plan.Storage.ValueString() + ":vztmpl/" + plan.FileName.ValueString())
+
+	err = r.read(ctx, &plan)
+	if err != nil {
+		resp.Diagnostics.AddError(
+			"Error when reading file from datastore", err.Error(),
+		)
+	}
+
+	resp.State.Set(ctx, plan)
+	resp.Diagnostics.Append(diags...)
+}
+
+func (r *ociImageResource) read(
+	ctx context.Context,
+	model *ociImageModel,
+) error {
+	nodesClient := r.client.Node(model.Node.ValueString())
+	storageClient := nodesClient.Storage(model.Storage.ValueString())
+
+	datastoresFiles, err := storageClient.ListDatastoreFiles(ctx)
+	if err != nil {
+		return fmt.Errorf("unexpected error when listing datastore files: %w", err)
+	}
+
+	for _, file := range datastoresFiles {
+		if file != nil {
+			if file.VolumeID != model.ID.ValueString() {
+				continue
+			}
+
+			model.Size = types.Int64Value(file.FileSize)
+
+			return nil
+		}
+	}
+
+	return fmt.Errorf("OCI image does not exist in datastore")
+}
+
+// Read reads file from datastore.
+func (r *ociImageResource) Read(
+	ctx context.Context,
+	req resource.ReadRequest,
+	resp *resource.ReadResponse,
+) {
+	var state ociImageModel
+
+	diags := req.State.Get(ctx, &state)
+
+	resp.Diagnostics.Append(diags...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	setOriginalValue := []byte(strconv.FormatInt(state.Size.ValueInt64(), 10))
+	resp.Private.SetKey(ctx, "original_state_size", setOriginalValue)
+
+	err := r.read(ctx, &state)
+	if err != nil {
+		if strings.Contains(err.Error(), "failed to authenticate") {
+			resp.Diagnostics.AddError("Failed to authenticate", err.Error())
+
+			return
+		}
+
+		resp.Diagnostics.AddWarning(
+			"The OCI image does not exist in datastore and resource must be recreated.",
+			err.Error(),
+		)
+		resp.State.RemoveResource(ctx)
+
+		return
+	}
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+// Update file resource.
+func (r *ociImageResource) Update(
+	ctx context.Context,
+	req resource.UpdateRequest,
+	resp *resource.UpdateResponse,
+) {
+	var plan, state ociImageModel
+
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
+	err := r.read(ctx, &plan)
+	if err != nil {
+		resp.Diagnostics.AddError(
+			"Error when reading OCI Image from datastore", err.Error(),
+		)
+
+		return
+	}
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
+}
+
+//nolint:dupl
+func (r *ociImageResource) Delete(
+	ctx context.Context,
+	req resource.DeleteRequest,
+	resp *resource.DeleteResponse,
+) {
+	var state ociImageModel
+
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	nodesClient := r.client.Node(state.Node.ValueString())
+	storageClient := nodesClient.Storage(state.Storage.ValueString())
+
+	err := storageClient.DeleteDatastoreFile(
+		ctx,
+		state.ID.ValueString(),
+	)
+	if err != nil && !errors.Is(err, api.ErrResourceDoesNotExist) {
+		if strings.Contains(err.Error(), "unable to parse") {
+			resp.Diagnostics.AddWarning(
+				"Datastore OCI image does not exists",
+				fmt.Sprintf(
+					"Could not delete datastore OCI image '%s', it does not exist or has been deleted outside of Terraform.",
+					state.ID.ValueString(),
+				),
+			)
+		} else {
+			resp.Diagnostics.AddError(
+				"Error deleting datastore OCI image",
+				fmt.Sprintf("Could not delete datastore OCI image '%s', unexpected error: %s",
+					state.ID.ValueString(), err.Error()),
+			)
+		}
+	}
+}
diff --git a/fwprovider/provider.go b/fwprovider/provider.go
index 828f9270..fda76f45 100644
--- a/fwprovider/provider.go
+++ b/fwprovider/provider.go
@@ -531,6 +531,7 @@ func (p *proxmoxProvider) Resources(_ context.Context) []func() resource.Resourc
 		network.NewLinuxBridgeResource,
 		network.NewLinuxVLANResource,
 		nodes.NewDownloadFileResource,
+		nodes.NewOCIImageResource,
 		options.NewClusterOptionsResource,
 		vm.NewResource,
 		sdnzone.NewSimpleResource,
diff --git a/main.go b/main.go
index ea74f7b8..30b38614 100644
--- a/main.go
+++ b/main.go
@@ -87,6 +87,7 @@ import (
 //go:generate cp ./build/docs-gen/resources/virtual_environment_vm2.md ./docs/resources/
 //go:generate cp ./build/docs-gen/resources/virtual_environment_metrics_server.md ./docs/resources/
 //go:generate cp ./build/docs-gen/resources/virtual_environment_pool_membership.md ./docs/resources/
+//go:generate cp ./build/docs-gen/resources/virtual_environment_oci_image.md ./docs/resources/
 
 // these will be set by the goreleaser configuration
 // to appropriate values for the compiled binary.
diff --git a/plan.md b/plan.md
new file mode 100644
index 00000000..5b672220
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,44 @@
+# Planned Project Modifications
+
+## Columns to be Added:
+- Backlog
+- Triage
+- Needs Info
+- P0
+- P1
+- Enhancements
+- In Progress
+- Review/QA
+- Blocked
+- Done
+
+## Custom Fields to be Added:
+- **Priority:** (enum: P0, P1, P2, P3)
+- **Severity:** (enum: blocker, major, minor, cosmetic)
+- **Component:** (text)
+- **Estimate:** (number)
+- **Linked PR/Branch:** (url)
+- **Next Action:** (text)
+
+## Labels to be Created/Updated:
+- **New Labels:**
+  - priority:P0
+  - priority:P1
+  - priority:P2
+  - priority:P3
+  - type:bug
+  - type:enhancement
+  - lifecycle:needs-repro
+  - lifecycle:needs-info (reuse existing "pending author's response")
+  - lifecycle:acknowledged
+  - status:in-progress
+  - status:blocked
+  - regression
+  - docs
+  - tests
+
+## Automation Rules to be Created:
+- Label priority:* moves card to corresponding column (priority:P0 -> P0 column etc.)
+- Label lifecycle:needs-info -> move to Needs Info
+- When a PR is opened referencing an issue, move card to In Progress
+- When PR merged, move card to Review/QA
\ No newline at end of file
diff --git a/proxmox/nodes/containers/containers_types.go b/proxmox/nodes/containers/containers_types.go
index 347c6a88..b4fb5ab2 100644
--- a/proxmox/nodes/containers/containers_types.go
+++ b/proxmox/nodes/containers/containers_types.go
@@ -11,6 +11,7 @@ import (
 	"fmt"
 	"net/url"
 	"regexp"
+	"sort"
 	"strconv"
 	"strings"
 
@@ -48,48 +49,52 @@ type CloneRequestBody struct {
 
 // CreateRequestBody contains the data for a user create request.
 type CreateRequestBody struct {
-	BandwidthLimit       *float64                 `json:"bwlimit,omitempty"              url:"bwlimit,omitempty"`
-	ConsoleEnabled       *types.CustomBool        `json:"console,omitempty"              url:"console,omitempty,int"`
-	ConsoleMode          *string                  `json:"cmode,omitempty"                url:"cmode,omitempty"`
-	CPUArchitecture      *string                  `json:"arch,omitempty"                 url:"arch,omitempty"`
-	CPUCores             *int                     `json:"cores,omitempty"                url:"cores,omitempty"`
-	CPULimit             *int                     `json:"cpulimit,omitempty"             url:"cpulimit,omitempty"`
-	CPUUnits             *int                     `json:"cpuunits,omitempty"             url:"cpuunits,omitempty"`
-	DatastoreID          *string                  `json:"storage,omitempty"              url:"storage,omitempty"`
-	DedicatedMemory      *int                     `json:"memory,omitempty"               url:"memory,omitempty"`
-	Delete               []string                 `json:"delete,omitempty"               url:"delete,omitempty"`
-	Description          *string                  `json:"description,omitempty"          url:"description,omitempty"`
-	DNSDomain            *string                  `json:"searchdomain,omitempty"         url:"searchdomain,omitempty"`
-	DNSServer            *string                  `json:"nameserver,omitempty"           url:"nameserver,omitempty"`
-	Features             *CustomFeatures          `json:"features,omitempty"             url:"features,omitempty"`
-	Force                *types.CustomBool        `json:"force,omitempty"                url:"force,omitempty,int"`
-	HookScript           *string                  `json:"hookscript,omitempty"           url:"hookscript,omitempty"`
-	Hostname             *string                  `json:"hostname,omitempty"             url:"hostname,omitempty"`
-	IgnoreUnpackErrors   *types.CustomBool        `json:"ignore-unpack-errors,omitempty" url:"force,omitempty,int"`
-	Lock                 *string                  `json:"lock,omitempty"                 url:"lock,omitempty,int"`
-	MountPoints          CustomMountPoints        `json:"mp,omitempty"                   url:"mp,omitempty,numbered"`
-	PassthroughDevices   CustomPassthroughDevices `json:"dev,omitempty"                  url:"dev,omitempty,numbered"`
-	NetworkInterfaces    CustomNetworkInterfaces  `json:"net,omitempty"                  url:"net,omitempty,numbered"`
-	OSTemplateFileVolume *string                  `json:"ostemplate,omitempty"           url:"ostemplate,omitempty"`
-	OSType               *string                  `json:"ostype,omitempty"               url:"ostype,omitempty"`
-	Password             *string                  `json:"password,omitempty"             url:"password,omitempty"`
-	PoolID               *string                  `json:"pool,omitempty"                 url:"pool,omitempty"`
-	Protection           *types.CustomBool        `json:"protection,omitempty"           url:"protection,omitempty,int"`
-	Restore              *types.CustomBool        `json:"restore,omitempty"              url:"restore,omitempty,int"`
-	RootFS               *CustomRootFS            `json:"rootfs,omitempty"               url:"rootfs,omitempty"`
-	SSHKeys              *CustomSSHKeys           `json:"ssh-public-keys,omitempty"      url:"ssh-public-keys,omitempty"`
-	Start                *types.CustomBool        `json:"start,omitempty"                url:"start,omitempty,int"`
-	StartOnBoot          *types.CustomBool        `json:"onboot,omitempty"               url:"onboot,omitempty,int"`
-	StartupBehavior      *CustomStartupBehavior   `json:"startup,omitempty"              url:"startup,omitempty"`
-	Swap                 *int                     `json:"swap,omitempty"                 url:"swap,omitempty"`
-	Tags                 *string                  `json:"tags,omitempty"                 url:"tags,omitempty"`
-	Template             *types.CustomBool        `json:"template,omitempty"             url:"template,omitempty,int"`
-	TTY                  *int                     `json:"tty,omitempty"                  url:"tty,omitempty"`
-	Unique               *types.CustomBool        `json:"unique,omitempty"               url:"unique,omitempty,int"`
-	Unprivileged         *types.CustomBool        `json:"unprivileged,omitempty"         url:"unprivileged,omitempty,int"`
-	VMID                 *int                     `json:"vmid,omitempty"                 url:"vmid,omitempty"`
+	BandwidthLimit       *float64                    `json:"bwlimit,omitempty"              url:"bwlimit,omitempty"`
+	ConsoleEnabled       *types.CustomBool           `json:"console,omitempty"              url:"console,omitempty,int"`
+	ConsoleMode          *string                     `json:"cmode,omitempty"                url:"cmode,omitempty"`
+	CPUArchitecture      *string                     `json:"arch,omitempty"                 url:"arch,omitempty"`
+	CPUCores             *int                        `json:"cores,omitempty"                url:"cores,omitempty"`
+	CPULimit             *int                        `json:"cpulimit,omitempty"             url:"cpulimit,omitempty"`
+	CPUUnits             *int                        `json:"cpuunits,omitempty"             url:"cpuunits,omitempty"`
+	DatastoreID          *string                     `json:"storage,omitempty"              url:"storage,omitempty"`
+	DedicatedMemory      *int                        `json:"memory,omitempty"               url:"memory,omitempty"`
+	Delete               []string                    `json:"delete,omitempty"               url:"delete,omitempty"`
+	Description          *string                     `json:"description,omitempty"          url:"description,omitempty"`
+	DNSDomain            *string                     `json:"searchdomain,omitempty"         url:"searchdomain,omitempty"`
+	DNSServer            *string                     `json:"nameserver,omitempty"           url:"nameserver,omitempty"`
+	EnvironmentVariables *CustomEnvironmentVariables `json:"env,omitempty"                  url:"env,omitempty"`
+	Features             *CustomFeatures             `json:"features,omitempty"             url:"features,omitempty"`
+	Force                *types.CustomBool           `json:"force,omitempty"                url:"force,omitempty,int"`
+	HookScript           *string                     `json:"hookscript,omitempty"           url:"hookscript,omitempty"`
+	Hostname             *string                     `json:"hostname,omitempty"             url:"hostname,omitempty"`
+	IgnoreUnpackErrors   *types.CustomBool           `json:"ignore-unpack-errors,omitempty" url:"force,omitempty,int"`
+	Lock                 *string                     `json:"lock,omitempty"                 url:"lock,omitempty,int"`
+	MountPoints          CustomMountPoints           `json:"mp,omitempty"                   url:"mp,omitempty,numbered"`
+	PassthroughDevices   CustomPassthroughDevices    `json:"dev,omitempty"                  url:"dev,omitempty,numbered"`
+	NetworkInterfaces    CustomNetworkInterfaces     `json:"net,omitempty"                  url:"net,omitempty,numbered"`
+	OSTemplateFileVolume *string                     `json:"ostemplate,omitempty"           url:"ostemplate,omitempty"`
+	OSType               *string                     `json:"ostype,omitempty"               url:"ostype,omitempty"`
+	Password             *string                     `json:"password,omitempty"             url:"password,omitempty"`
+	PoolID               *string                     `json:"pool,omitempty"                 url:"pool,omitempty"`
+	Protection           *types.CustomBool           `json:"protection,omitempty"           url:"protection,omitempty,int"`
+	Restore              *types.CustomBool           `json:"restore,omitempty"              url:"restore,omitempty,int"`
+	RootFS               *CustomRootFS               `json:"rootfs,omitempty"               url:"rootfs,omitempty"`
+	SSHKeys              *CustomSSHKeys              `json:"ssh-public-keys,omitempty"      url:"ssh-public-keys,omitempty"`
+	Start                *types.CustomBool           `json:"start,omitempty"                url:"start,omitempty,int"`
+	StartOnBoot          *types.CustomBool           `json:"onboot,omitempty"               url:"onboot,omitempty,int"`
+	StartupBehavior      *CustomStartupBehavior      `json:"startup,omitempty"              url:"startup,omitempty"`
+	Swap                 *int                        `json:"swap,omitempty"                 url:"swap,omitempty"`
+	Tags                 *string                     `json:"tags,omitempty"                 url:"tags,omitempty"`
+	Template             *types.CustomBool           `json:"template,omitempty"             url:"template,omitempty,int"`
+	TTY                  *int                        `json:"tty,omitempty"                  url:"tty,omitempty"`
+	Unique               *types.CustomBool           `json:"unique,omitempty"               url:"unique,omitempty,int"`
+	Unprivileged         *types.CustomBool           `json:"unprivileged,omitempty"         url:"unprivileged,omitempty,int"`
+	VMID                 *int                        `json:"vmid,omitempty"                 url:"vmid,omitempty"`
 }
 
+// CustomEnvironmentVariables contains environment variable key-value pairs.
+type CustomEnvironmentVariables map[string]string
+
 // CustomFeatures contains the values for the "features" property.
 type CustomFeatures struct {
 	FUSE       *types.CustomBool `json:"fuse,omitempty"    url:"fuse,omitempty,int"`
@@ -189,35 +194,36 @@ type GetResponseBody struct {
 
 // GetResponseData contains the data from a user get response.
 type GetResponseData struct {
-	ConsoleEnabled     *types.CustomBool        `json:"console,omitempty"`
-	ConsoleMode        *string                  `json:"cmode,omitempty"`
-	CPUArchitecture    *string                  `json:"arch,omitempty"`
-	CPUCores           *int                     `json:"cores,omitempty"`
-	CPULimit           *types.CustomInt         `json:"cpulimit,omitempty"`
-	CPUUnits           *int                     `json:"cpuunits,omitempty"`
-	DedicatedMemory    *int                     `json:"memory,omitempty"`
-	Description        *string                  `json:"description,omitempty"`
-	Digest             string                   `json:"digest"`
-	DNSDomain          *string                  `json:"searchdomain,omitempty"`
-	DNSServer          *string                  `json:"nameserver,omitempty"`
-	Features           *CustomFeatures          `json:"features,omitempty"`
-	HookScript         *string                  `json:"hookscript,omitempty"`
-	Hostname           *string                  `json:"hostname,omitempty"`
-	Lock               *types.CustomBool        `json:"lock,omitempty"`
-	LXCConfiguration   *[][2]string             `json:"lxc,omitempty"`
-	MountPoints        CustomMountPoints        `json:"mp,omitempty"`
-	PassthroughDevices CustomPassthroughDevices `json:"dev,omitempty"`
-	NetworkInterfaces  CustomNetworkInterfaces  `json:"net,omitempty"`
-	OSType             *string                  `json:"ostype,omitempty"`
-	Protection         *types.CustomBool        `json:"protection,omitempty"`
-	RootFS             *CustomRootFS            `json:"rootfs,omitempty"`
-	StartOnBoot        *types.CustomBool        `json:"onboot,omitempty"`
-	StartupBehavior    *CustomStartupBehavior   `json:"startup,omitempty"`
-	Swap               *int                     `json:"swap,omitempty"`
-	Tags               *string                  `json:"tags,omitempty"`
-	Template           *types.CustomBool        `json:"template,omitempty"`
-	TTY                *int                     `json:"tty,omitempty"`
-	Unprivileged       *types.CustomBool        `json:"unprivileged,omitempty"`
+	ConsoleEnabled       *types.CustomBool           `json:"console,omitempty"`
+	ConsoleMode          *string                     `json:"cmode,omitempty"`
+	CPUArchitecture      *string                     `json:"arch,omitempty"`
+	CPUCores             *int                        `json:"cores,omitempty"`
+	CPULimit             *types.CustomInt            `json:"cpulimit,omitempty"`
+	CPUUnits             *int                        `json:"cpuunits,omitempty"`
+	DedicatedMemory      *int                        `json:"memory,omitempty"`
+	Description          *string                     `json:"description,omitempty"`
+	Digest               string                      `json:"digest"`
+	DNSDomain            *string                     `json:"searchdomain,omitempty"`
+	DNSServer            *string                     `json:"nameserver,omitempty"`
+	EnvironmentVariables *CustomEnvironmentVariables `json:"env,omitempty"`
+	Features             *CustomFeatures             `json:"features,omitempty"`
+	HookScript           *string                     `json:"hookscript,omitempty"`
+	Hostname             *string                     `json:"hostname,omitempty"`
+	Lock                 *types.CustomBool           `json:"lock,omitempty"`
+	LXCConfiguration     *[][2]string                `json:"lxc,omitempty"`
+	MountPoints          CustomMountPoints           `json:"mp,omitempty"`
+	PassthroughDevices   CustomPassthroughDevices    `json:"dev,omitempty"`
+	NetworkInterfaces    CustomNetworkInterfaces     `json:"net,omitempty"`
+	OSType               *string                     `json:"ostype,omitempty"`
+	Protection           *types.CustomBool           `json:"protection,omitempty"`
+	RootFS               *CustomRootFS               `json:"rootfs,omitempty"`
+	StartOnBoot          *types.CustomBool           `json:"onboot,omitempty"`
+	StartupBehavior      *CustomStartupBehavior      `json:"startup,omitempty"`
+	Swap                 *int                        `json:"swap,omitempty"`
+	Tags                 *string                     `json:"tags,omitempty"`
+	Template             *types.CustomBool           `json:"template,omitempty"`
+	TTY                  *int                        `json:"tty,omitempty"`
+	Unprivileged         *types.CustomBool           `json:"unprivileged,omitempty"`
 }
 
 // GetStatusResponseBody contains the body from a container get status response.
@@ -649,6 +655,31 @@ func (r *CustomStartupBehavior) EncodeValues(
 	return nil
 }
 
+// EncodeValues converts a CustomEnvironmentVariables map to a NUL-separated string.
+func (r CustomEnvironmentVariables) EncodeValues(key string, v *url.Values) error {
+	if len(r) == 0 {
+		return nil
+	}
+
+	keys := make([]string, 0, len(r))
+	for k := range r {
+		keys = append(keys, k)
+	}
+
+	sort.Strings(keys)
+
+	envVars := make([]string, 0, len(r))
+
+	for _, k := range keys {
+		envVars = append(envVars, fmt.Sprintf("%s=%s", k, r[k]))
+	}
+
+	// join with NUL character (ASCII 0)
+	v.Add(key, strings.Join(envVars, "\x00"))
+
+	return nil
+}
+
 // UnmarshalJSON converts a ContainerCustomFeatures string to an object.
 func (r *CustomFeatures) UnmarshalJSON(b []byte) error {
 	var s string
@@ -940,6 +971,30 @@ func (r *CustomStartupBehavior) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
+// UnmarshalJSON converts a CustomEnvironmentVariables NUL-separated string to a map.
+func (r *CustomEnvironmentVariables) UnmarshalJSON(b []byte) error {
+	var s string
+
+	if err := json.Unmarshal(b, &s); err != nil {
+		return fmt.Errorf("unable to unmarshal CustomEnvironmentVariables: %w", err)
+	}
+
+	*r = make(CustomEnvironmentVariables)
+
+	// Split by NUL character (ASCII 0)
+	pairs := strings.SplitSeq(s, "\x00")
+
+	for p := range pairs {
+		v := strings.SplitN(p, "=", 2)
+
+		if len(v) == 2 {
+			(*r)[v[0]] = v[1]
+		}
+	}
+
+	return nil
+}
+
 // UnmarshalJSON unmarshals the data from the JSON response, populating the CustomStorageDevices field.
 func (d *GetResponseData) UnmarshalJSON(b []byte) error {
 	type Alias GetResponseData
diff --git a/proxmox/nodes/storage/oci_registry_pull.go b/proxmox/nodes/storage/oci_registry_pull.go
new file mode 100644
index 00000000..1a5fb073
--- /dev/null
+++ b/proxmox/nodes/storage/oci_registry_pull.go
@@ -0,0 +1,50 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package storage
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/bpg/terraform-provider-proxmox/proxmox/api"
+)
+
+// DownloadOCIImageByReference downloads the OCI image by its reference.
+func (c *Client) DownloadOCIImageByReference(
+	ctx context.Context,
+	d *OCIRegistryPullRequestBody,
+) error {
+	resBody := &OCIRegistryPullResponseBody{}
+
+	err := c.DoRequest(ctx, http.MethodPost, c.ExpandPath("oci-registry-pull"), d, resBody)
+	if err != nil {
+		return fmt.Errorf("error download OCI image by reference: %w", err)
+	}
+
+	if resBody.TaskID == nil {
+		return api.ErrNoDataObjectInResponse
+	}
+
+	taskErr := c.Tasks().WaitForTask(ctx, *resBody.TaskID)
+	if taskErr != nil {
+		err = fmt.Errorf(
+			"error download OCI image to datastore %s: failed waiting for OCI image download: %w",
+			c.StorageName,
+			taskErr,
+		)
+
+		deleteErr := c.Tasks().DeleteTask(context.WithoutCancel(ctx), *resBody.TaskID)
+		if deleteErr != nil {
+			return fmt.Errorf("%w \n %w", err, deleteErr)
+		}
+
+		return err
+	}
+
+	return nil
+}
diff --git a/proxmox/nodes/storage/oci_registry_pull_types.go b/proxmox/nodes/storage/oci_registry_pull_types.go
new file mode 100644
index 00000000..50ebd22c
--- /dev/null
+++ b/proxmox/nodes/storage/oci_registry_pull_types.go
@@ -0,0 +1,19 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package storage
+
+// OCIRegistryPullResponseBody contains the response for an OCI registry pull request.
+type OCIRegistryPullResponseBody struct {
+	TaskID *string `json:"data,omitempty"`
+}
+
+// OCIRegistryPullRequestBody contains the data for an OCI registry pull request.
+type OCIRegistryPullRequestBody struct {
+	FileName  *string `json:"filename,omitempty"  url:"filename,omitempty"`
+	Reference *string `json:"reference,omitempty" url:"reference,omitempty"`
+	Storage   *string `json:"storage,omitempty"   url:"storage,omitempty"`
+}
diff --git a/proxmoxtf/resource/container/container.go b/proxmoxtf/resource/container/container.go
index 76ca2ea4..d9561736 100644
--- a/proxmoxtf/resource/container/container.go
+++ b/proxmoxtf/resource/container/container.go
@@ -116,6 +116,7 @@ const (
 	mkDiskQuota                         = "quota"
 	mkDiskReplicate                     = "replicate"
 	mkDiskSize                          = "size"
+	mkEnvironmentVariables              = "environment_variables"
 	mkFeatures                          = "features"
 	mkFeaturesNesting                   = "nesting"
 	mkFeaturesKeyControl                = "keyctl"
@@ -397,6 +398,15 @@ func Container() *schema.Resource {
 				MaxItems: 1,
 				MinItems: 0,
 			},
+			mkEnvironmentVariables: {
+				Type:        schema.TypeMap,
+				Description: "The runtime environment variables for the container init process.",
+				Optional:    true,
+				Elem: &schema.Schema{
+					Type: schema.TypeString,
+				},
+				ValidateDiagFunc: EnvironmentVariablesValidator(),
+			},
 			mkFeatures: {
 				Type:        schema.TypeList,
 				Description: "Features",
@@ -1581,6 +1591,8 @@ func containerCreateCustom(ctx context.Context, d *schema.ResourceData, m any) d
 
 	diskDatastoreID := diskBlock[mkDiskDatastoreID].(string)
 
+	environmentVariables := containerGetEnvironmentVariables(d)
+
 	features, err := containerGetFeatures(container, d)
 	if err != nil {
 		return diag.FromErr(err)
@@ -1978,6 +1990,10 @@ func containerCreateCustom(ctx context.Context, d *schema.ResourceData, m any) d
 		createBody.Tags = &tagsString
 	}
 
+	if environmentVariables != nil {
+		createBody.EnvironmentVariables = environmentVariables
+	}
+
 	err = client.Node(nodeName).Container(0).CreateContainer(ctx, &createBody)
 	if err != nil {
 		return diag.FromErr(err)
@@ -2021,6 +2037,20 @@ func containerCreateStart(ctx context.Context, d *schema.ResourceData, m any) di
 	return containerRead(ctx, d, m)
 }
 
+func containerGetEnvironmentVariables(d *schema.ResourceData) *containers.CustomEnvironmentVariables {
+	envVarsRaw := d.Get(mkEnvironmentVariables).(map[string]any)
+	if len(envVarsRaw) == 0 {
+		return nil
+	}
+
+	envVars := make(containers.CustomEnvironmentVariables)
+	for k, v := range envVarsRaw {
+		envVars[k] = v.(string)
+	}
+
+	return &envVars
+}
+
 // NOTE: this function is NOT used in `read`!
 func containerGetExistingNetworkInterface(
 	ctx context.Context,
@@ -2834,6 +2864,17 @@ func containerRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diag
 		diags = append(diags, diag.FromErr(e)...)
 	}
 
+	envVarsMap := make(map[string]any)
+
+	if containerConfig.EnvironmentVariables != nil {
+		for k, v := range *containerConfig.EnvironmentVariables {
+			envVarsMap[k] = v
+		}
+	}
+
+	e = d.Set(mkEnvironmentVariables, envVarsMap)
+	diags = append(diags, diag.FromErr(e)...)
+
 	if len(clone) == 0 || template {
 		if containerConfig.Template != nil {
 			e = d.Set(
@@ -3040,6 +3081,17 @@ func containerUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Di
 		rebootRequired = true
 	}
 
+	if d.HasChange(mkEnvironmentVariables) {
+		environmentVariables := containerGetEnvironmentVariables(d)
+		if environmentVariables != nil {
+			updateBody.EnvironmentVariables = environmentVariables
+		} else {
+			updateBody.Delete = append(updateBody.Delete, "env")
+		}
+
+		rebootRequired = true
+	}
+
 	if d.HasChange(mkFeatures) {
 		features, err := containerGetFeatures(container, d)
 		if err != nil {
diff --git a/proxmoxtf/resource/container/validators.go b/proxmoxtf/resource/container/validators.go
index d41705f9..8bfce12c 100644
--- a/proxmoxtf/resource/container/validators.go
+++ b/proxmoxtf/resource/container/validators.go
@@ -7,6 +7,9 @@
 package resource
 
 import (
+	"fmt"
+	"regexp"
+
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
 )
@@ -54,3 +57,34 @@ func OperatingSystemTypeValidator() schema.SchemaValidateDiagFunc {
 		"unmanaged",
 	}, false))
 }
+
+// EnvironmentVariablesValidator validates environment variable map based on Proxmox API requirements.
+func EnvironmentVariablesValidator() schema.SchemaValidateDiagFunc {
+	// Proxmox regex for keys: \w+ (one or more word characters)
+	keyRegex := regexp.MustCompile(`^\w+$`)
+
+	// Proxmox rejects these control characters in values: \x00-\x08, \x10-\x1F, \x7F
+	invalidValueCharsRegex := regexp.MustCompile(`[\x00-\x08\x10-\x1F\x7F]`)
+
+	return validation.ToDiagFunc(func(i any, k string) ([]string, []error) {
+		var es []error
+
+		envVars, ok := i.(map[string]any)
+		if !ok {
+			es = append(es, fmt.Errorf("expected type of %s to be map[string]any", k))
+			return nil, es
+		}
+
+		for key, val := range envVars {
+			if !keyRegex.MatchString(key) {
+				es = append(es, fmt.Errorf("environment variable key '%s' is invalid. Keys must contain only letters, digits, and underscores", key))
+			}
+
+			if valStr, ok := val.(string); ok && invalidValueCharsRegex.MatchString(valStr) {
+				es = append(es, fmt.Errorf("environment variable '%s' has an invalid value. Values cannot contain control characters", key))
+			}
+		}
+
+		return nil, es
+	})
+}



Test patch:
diff --git a/fwprovider/cluster/sdn/applier/resource_test.go b/fwprovider/cluster/sdn/applier/resource_test.go
index 83dc3ba6..26f81fa0 100644
--- a/fwprovider/cluster/sdn/applier/resource_test.go
+++ b/fwprovider/cluster/sdn/applier/resource_test.go
@@ -133,7 +133,7 @@ func TestAccResourceSDNApplier(t *testing.T) {
 						depends_on = [
 							proxmox_virtual_environment_sdn_applier.finalizer
 						]
-					}	
+					}
 
 					resource "proxmox_virtual_environment_sdn_applier" "update_applier" {
 						lifecycle {
@@ -458,3 +458,169 @@ func TestAccResourceSDNApplier(t *testing.T) {
 		})
 	}
 }
+
+func TestAccResourceSDNApplierOnParameters(t *testing.T) {
+	te := test.InitEnvironment(t)
+
+	tests := []struct {
+		name  string
+		steps []resource.TestStep
+	}{
+		{"applier with on_create=false", []resource.TestStep{
+			{
+				Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_sdn_zone_simple" "no_create_zone" {
+					id    = "nocZ"
+					nodes = ["{{.NodeName}}"]
+					mtu   = 1500
+				}
+				resource "proxmox_virtual_environment_sdn_applier" "no_create_applier" {
+					on_create = false
+					depends_on = [
+						proxmox_virtual_environment_sdn_zone_simple.no_create_zone,
+					]
+				}
+				data "proxmox_virtual_environment_sdn_zone_simple" "zone" {
+					id = proxmox_virtual_environment_sdn_zone_simple.no_create_zone.id
+					depends_on = [
+						proxmox_virtual_environment_sdn_applier.no_create_applier,
+					]
+				}
+				`),
+				Check: resource.ComposeTestCheckFunc(
+					test.ResourceAttributesSet("proxmox_virtual_environment_sdn_applier.no_create_applier", []string{
+						"id",
+					}),
+					test.ResourceAttributes("proxmox_virtual_environment_sdn_applier.no_create_applier", map[string]string{
+						"on_create": "false",
+					}),
+					test.ResourceAttributes("data.proxmox_virtual_environment_sdn_zone_simple.zone", map[string]string{
+						"pending": "true",
+					}),
+				),
+			},
+		}},
+		{"applier with on_destroy=false", []resource.TestStep{
+			{
+				Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_sdn_zone_simple" "no_destroy_zone" {
+					id    = "nodZ"
+					nodes = ["{{.NodeName}}"]
+					mtu   = 1500
+					depends_on = [
+						proxmox_virtual_environment_sdn_applier.no_destroy_applier,
+					]
+				}
+				resource "proxmox_virtual_environment_sdn_applier" "no_destroy_applier" {
+					on_destroy = false
+				}
+				data "proxmox_virtual_environment_sdn_zone_simple" "zone" {
+					id = proxmox_virtual_environment_sdn_zone_simple.no_destroy_zone.id
+					depends_on = [
+						proxmox_virtual_environment_sdn_applier.no_destroy_applier,
+					]
+				}
+				`),
+				Check: resource.ComposeTestCheckFunc(
+					test.ResourceAttributesSet("proxmox_virtual_environment_sdn_applier.no_destroy_applier", []string{
+						"id",
+					}),
+					test.ResourceAttributes("proxmox_virtual_environment_sdn_applier.no_destroy_applier", map[string]string{
+						"on_destroy": "false",
+					}),
+					test.ResourceAttributes("data.proxmox_virtual_environment_sdn_zone_simple.zone", map[string]string{
+						"pending": "true",
+					}),
+				),
+			},
+			{
+				Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_sdn_zone_simple" "no_destroy_zone" {
+					id    = "nodZ"
+					nodes = ["{{.NodeName}}"]
+					mtu   = 1500
+				}
+				`),
+				Check: resource.ComposeTestCheckFunc(
+					test.ResourceAttributes("proxmox_virtual_environment_sdn_zone_simple.no_destroy_zone", map[string]string{
+						"pending": "true",
+					}),
+				),
+			},
+		}},
+		{"applier with default values (all true)", []resource.TestStep{
+			{
+				Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_sdn_zone_simple" "default_zone" {
+					id    = "defZ"
+					nodes = ["{{.NodeName}}"]
+					mtu   = 1500
+					depends_on = [
+						proxmox_virtual_environment_sdn_applier.default_applier_2,
+					]
+				}
+				resource "proxmox_virtual_environment_sdn_applier" "default_applier_1" {
+					depends_on = [
+						proxmox_virtual_environment_sdn_zone_simple.default_zone,
+					]
+				}
+				resource "proxmox_virtual_environment_sdn_applier" "default_applier_2" {
+				}
+				data "proxmox_virtual_environment_sdn_zone_simple" "zone" {
+					id = proxmox_virtual_environment_sdn_zone_simple.default_zone.id
+					depends_on = [
+						proxmox_virtual_environment_sdn_applier.default_applier_1,
+						proxmox_virtual_environment_sdn_applier.default_applier_2,
+					]
+				}
+				`),
+				Check: resource.ComposeTestCheckFunc(
+					test.ResourceAttributesSet("proxmox_virtual_environment_sdn_applier.default_applier_1", []string{
+						"id",
+					}),
+					test.ResourceAttributesSet("proxmox_virtual_environment_sdn_applier.default_applier_2", []string{
+						"id",
+					}),
+					test.ResourceAttributes("proxmox_virtual_environment_sdn_applier.default_applier_1", map[string]string{
+						"on_create":  "true",
+						"on_destroy": "true",
+					}),
+					test.ResourceAttributes("proxmox_virtual_environment_sdn_applier.default_applier_2", map[string]string{
+						"on_create":  "true",
+						"on_destroy": "true",
+					}),
+					test.NoResourceAttributesSet("data.proxmox_virtual_environment_sdn_zone_simple.zone", []string{
+						"pending",
+					}),
+				),
+			},
+			{
+				Config: te.RenderConfig(`
+					resource "proxmox_virtual_environment_sdn_zone_simple" "default_zone" {
+						id    = "defZ"
+						nodes = ["{{.NodeName}}"]
+						mtu   = 1500
+					}
+
+					data "proxmox_virtual_environment_sdn_zone_simple" "zone" {
+						id = proxmox_virtual_environment_sdn_zone_simple.default_zone.id
+					}
+				`),
+				Check: resource.ComposeTestCheckFunc(
+					test.NoResourceAttributesSet("data.proxmox_virtual_environment_sdn_zone_simple.zone", []string{
+						"pending",
+					}),
+				),
+			},
+		}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			resource.Test(t, resource.TestCase{
+				ProtoV6ProviderFactories: te.AccProviders,
+				Steps:                    tt.steps,
+			})
+		})
+	}
+}
diff --git a/fwprovider/nodes/resource_oci_image_test.go b/fwprovider/nodes/resource_oci_image_test.go
new file mode 100644
index 00000000..5a7b646a
--- /dev/null
+++ b/fwprovider/nodes/resource_oci_image_test.go
@@ -0,0 +1,281 @@
+//go:build acceptance || all
+
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package nodes_test
+
+import (
+	"context"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+	"github.com/hashicorp/terraform-plugin-testing/plancheck"
+	"github.com/stretchr/testify/require"
+
+	"github.com/bpg/terraform-provider-proxmox/fwprovider/test"
+	"github.com/bpg/terraform-provider-proxmox/proxmox/helpers/ptr"
+	"github.com/bpg/terraform-provider-proxmox/proxmox/nodes/storage"
+)
+
+const (
+	testOCIImage       = "docker.io/library/hello-world:latest"
+	testOCIImageAlpine = "docker.io/library/alpine:latest"
+)
+
+func TestAccResourceOCIImage(t *testing.T) {
+	te := test.InitEnvironment(t)
+
+	te.AddTemplateVars(map[string]interface{}{
+		"OCIImage": testOCIImage,
+	})
+
+	tests := []struct {
+		name  string
+		steps []resource.TestStep
+	}{
+		{"missing reference", []resource.TestStep{{
+			Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_oci_image" "test_image" {
+					node_name    = "{{.NodeName}}"
+					datastore_id = "{{.DatastoreID}}"
+					reference    = ""
+				}`),
+			ExpectError: regexp.MustCompile(`Attribute reference must match OCI image reference regex`),
+		}}},
+		{"pull OCI image without file_name", []resource.TestStep{{
+			Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_oci_image" "test_image" {
+					node_name          = "{{.NodeName}}"
+					datastore_id       = "{{.DatastoreID}}"
+					reference          = "{{.OCIImage}}"
+					overwrite_unmanaged = true
+				}`),
+			Check: resource.ComposeTestCheckFunc(
+				test.ResourceAttributes("proxmox_virtual_environment_oci_image.test_image", map[string]string{
+					"id":                  "local:vztmpl/hello-world_latest.tar",
+					"node_name":           te.NodeName,
+					"datastore_id":        te.DatastoreID,
+					"reference":           testOCIImage,
+					"file_name":           "hello-world_latest.tar",
+					"upload_timeout":      "600",
+					"overwrite":           "true",
+					"overwrite_unmanaged": "true",
+				}),
+				test.ResourceAttributesSet("proxmox_virtual_environment_oci_image.test_image", []string{
+					"size",
+				}),
+			),
+		}}},
+		{"pull OCI image with custom file_name", []resource.TestStep{{
+			Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_oci_image" "test_custom_name" {
+					node_name          = "{{.NodeName}}"
+					datastore_id       = "{{.DatastoreID}}"
+					reference          = "{{.OCIImage}}"
+					file_name          = "custom_hello_world.tar"
+					overwrite_unmanaged = true
+				}`),
+			Check: resource.ComposeTestCheckFunc(
+				test.ResourceAttributes("proxmox_virtual_environment_oci_image.test_custom_name", map[string]string{
+					"id":                  "local:vztmpl/custom_hello_world.tar",
+					"node_name":           te.NodeName,
+					"datastore_id":        te.DatastoreID,
+					"reference":           testOCIImage,
+					"file_name":           "custom_hello_world.tar",
+					"upload_timeout":      "600",
+					"overwrite":           "true",
+					"overwrite_unmanaged": "true",
+				}),
+				test.ResourceAttributesSet("proxmox_virtual_environment_oci_image.test_custom_name", []string{
+					"size",
+				}),
+			),
+		}}},
+		{"invalid file_name without .tar extension", []resource.TestStep{{
+			Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_oci_image" "test_invalid_name" {
+					node_name          = "{{.NodeName}}"
+					datastore_id       = "{{.DatastoreID}}"
+					reference          = "{{.OCIImage}}"
+					file_name          = "invalid_name.txt"
+				}`),
+			ExpectError: regexp.MustCompile(`file name must end with .tar`),
+		}}},
+		{"pull & update OCI image", []resource.TestStep{
+			{
+				Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_oci_image" "test_update" {
+					node_name          = "{{.NodeName}}"
+					datastore_id       = "{{.DatastoreID}}"
+					reference          = "{{.OCIImage}}"
+					file_name          = "test_update_image.tar"
+					overwrite_unmanaged = true
+				}`),
+				Check: resource.ComposeTestCheckFunc(
+					test.ResourceAttributes("proxmox_virtual_environment_oci_image.test_update", map[string]string{
+						"id":             "local:vztmpl/test_update_image.tar",
+						"node_name":      te.NodeName,
+						"datastore_id":   te.DatastoreID,
+						"reference":      testOCIImage,
+						"file_name":      "test_update_image.tar",
+						"upload_timeout": "600",
+					}),
+					test.ResourceAttributesSet("proxmox_virtual_environment_oci_image.test_update", []string{
+						"size",
+					}),
+				),
+			},
+			{
+				Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_oci_image" "test_update" {
+					node_name          = "{{.NodeName}}"
+					datastore_id       = "{{.DatastoreID}}"
+					reference          = "{{.OCIImage}}"
+					file_name          = "test_update_image.tar"
+					upload_timeout     = 1200
+					overwrite_unmanaged = true
+				}`),
+				Check: resource.ComposeTestCheckFunc(
+					test.ResourceAttributes("proxmox_virtual_environment_oci_image.test_update", map[string]string{
+						"id":             "local:vztmpl/test_update_image.tar",
+						"node_name":      te.NodeName,
+						"datastore_id":   te.DatastoreID,
+						"reference":      testOCIImage,
+						"file_name":      "test_update_image.tar",
+						"upload_timeout": "1200",
+					}),
+					test.ResourceAttributesSet("proxmox_virtual_environment_oci_image.test_update", []string{
+						"size",
+					}),
+				),
+			},
+		}},
+		{"override behavior", []resource.TestStep{{
+			Destroy: false,
+			PreConfig: func() {
+				ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+				defer cancel()
+
+				fileID := "vztmpl/test_override_image.tar"
+				_ = te.NodeStorageClient().DeleteDatastoreFile(ctx, fileID) //nolint: errcheck
+
+				// Pull OCI image outside of Terraform
+				filenameWithoutTar := "test_override_image"
+				err := te.NodeStorageClient().DownloadOCIImageByReference(ctx, &storage.OCIRegistryPullRequestBody{
+					Storage:   ptr.Ptr(te.DatastoreID),
+					FileName:  &filenameWithoutTar,
+					Reference: ptr.Ptr(testOCIImage),
+				})
+				require.NoError(t, err)
+
+				t.Cleanup(func() {
+					e := te.NodeStorageClient().DeleteDatastoreFile(context.Background(), fileID)
+					require.NoError(t, e)
+				})
+			},
+			Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_oci_image" "test_override" {
+					node_name           = "{{.NodeName}}"
+					datastore_id        = "{{.DatastoreID}}"
+					reference           = "{{.OCIImage}}"
+					file_name           = "test_override_another.tar"
+					overwrite_unmanaged = true
+					overwrite           = false
+				}`),
+			Check: resource.ComposeTestCheckFunc(
+				test.ResourceAttributes("proxmox_virtual_environment_oci_image.test_override", map[string]string{
+					"id":                  "local:vztmpl/test_override_another.tar",
+					"node_name":           te.NodeName,
+					"datastore_id":        te.DatastoreID,
+					"reference":           testOCIImage,
+					"file_name":           "test_override_another.tar",
+					"overwrite":           "false",
+					"overwrite_unmanaged": "true",
+				}),
+				test.ResourceAttributesSet("proxmox_virtual_environment_oci_image.test_override", []string{
+					"size",
+				}),
+			),
+		}, {
+			Destroy: false,
+			PreConfig: func() {
+				ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+				defer cancel()
+
+				fileID := "vztmpl/test_override_another.tar"
+
+				// Manually pull a different version to simulate external change
+				filenameWithoutTar := "test_override_another"
+				_ = te.NodeStorageClient().DeleteDatastoreFile(ctx, fileID) //nolint: errcheck
+
+				err := te.NodeStorageClient().DownloadOCIImageByReference(ctx, &storage.OCIRegistryPullRequestBody{
+					Storage:   ptr.Ptr(te.DatastoreID),
+					FileName:  &filenameWithoutTar,
+					Reference: ptr.Ptr(testOCIImage),
+				})
+				require.NoError(t, err)
+			},
+			Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_oci_image" "test_override" {
+					node_name           = "{{.NodeName}}"
+					datastore_id        = "{{.DatastoreID}}"
+					reference           = "{{.OCIImage}}"
+					file_name           = "test_override_another.tar"
+					overwrite_unmanaged = true
+					overwrite           = false
+				}`),
+			ConfigPlanChecks: resource.ConfigPlanChecks{
+				PreApply: []plancheck.PlanCheck{
+					plancheck.ExpectEmptyPlan(),
+				},
+			},
+		}, {
+			PreConfig: func() {
+				ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+				defer cancel()
+
+				fileID := "vztmpl/test_override_another.tar"
+
+				// Manually pull a different image to simulate external change with different size
+				filenameWithoutTar := "test_override_another"
+				_ = te.NodeStorageClient().DeleteDatastoreFile(ctx, fileID) //nolint: errcheck
+
+				err := te.NodeStorageClient().DownloadOCIImageByReference(ctx, &storage.OCIRegistryPullRequestBody{
+					Storage:   ptr.Ptr(te.DatastoreID),
+					FileName:  &filenameWithoutTar,
+					Reference: ptr.Ptr(testOCIImageAlpine),
+				})
+				require.NoError(t, err)
+			},
+			Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_oci_image" "test_override" {
+					node_name           = "{{.NodeName}}"
+					datastore_id        = "{{.DatastoreID}}"
+					reference           = "{{.OCIImage}}"
+					file_name           = "test_override_another.tar"
+					overwrite_unmanaged = true
+					overwrite           = true
+				}`),
+			ConfigPlanChecks: resource.ConfigPlanChecks{
+				PreApply: []plancheck.PlanCheck{
+					plancheck.ExpectResourceAction("proxmox_virtual_environment_oci_image.test_override", plancheck.ResourceActionDestroyBeforeCreate),
+				},
+			},
+		}}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			resource.ParallelTest(t, resource.TestCase{
+				ProtoV6ProviderFactories: te.AccProviders,
+				Steps:                    tt.steps,
+			})
+		})
+	}
+}
diff --git a/fwprovider/test/resource_container_test.go b/fwprovider/test/resource_container_test.go
index 92dde8e1..be70ec30 100644
--- a/fwprovider/test/resource_container_test.go
+++ b/fwprovider/test/resource_container_test.go
@@ -941,6 +941,166 @@ func TestAccResourceContainerIpv4Ipv6(t *testing.T) {
 	})
 }
 
+func TestAccResourceContainerEnvironmentVariables(t *testing.T) {
+	te := InitEnvironment(t)
+	accTestContainerID := 100000 + rand.Intn(99999)
+	imageFileName := fmt.Sprintf("%d-alpine-3.22-default_20250617_amd64.tar.xz", time.Now().UnixMicro())
+
+	testAccDownloadContainerTemplate(t, te, imageFileName)
+
+	te.AddTemplateVars(map[string]interface{}{
+		"ImageFileName":   imageFileName,
+		"TestContainerID": accTestContainerID,
+	})
+
+	resource.ParallelTest(t, resource.TestCase{
+		ProtoV6ProviderFactories: te.AccProviders,
+		Steps: []resource.TestStep{
+			{
+				Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_container" "test_container" {
+					node_name = "{{.NodeName}}"
+					vm_id     = {{.TestContainerID}}
+					unprivileged = true
+					disk {
+						datastore_id = "local-lvm"
+						size         = 4
+					}
+					initialization {
+						hostname = "test-env-vars"
+						ip_config {
+							ipv4 {
+								address = "dhcp"
+							}
+						}
+					}
+					network_interface {
+						name = "vmbr0"
+					}
+					operating_system {
+						template_file_id = "local:vztmpl/{{.ImageFileName}}"
+						type             = "alpine"
+					}
+					environment_variables = {
+						FOO = "bar"
+						BAZ = "qux"
+					}
+				}`, WithRootUser()),
+				Check: resource.ComposeTestCheckFunc(
+					ResourceAttributes(accTestContainerName, map[string]string{
+						"initialization.0.hostname": "test-env-vars",
+						"environment_variables.FOO": "bar",
+						"environment_variables.BAZ": "qux",
+						"environment_variables.%":   "2",
+					}),
+					func(*terraform.State) error {
+						ct := te.NodeClient().Container(accTestContainerID)
+						ctInfo, err := ct.GetContainer(t.Context())
+						require.NoError(te.t, err, "failed to get container")
+						require.NotNil(te.t, ctInfo.EnvironmentVariables, "environment_variables should not be nil")
+						require.Equal(te.t, "bar", (*ctInfo.EnvironmentVariables)["FOO"])
+						require.Equal(te.t, "qux", (*ctInfo.EnvironmentVariables)["BAZ"])
+
+						return nil
+					},
+				),
+			},
+			{
+				Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_container" "test_container" {
+					node_name = "{{.NodeName}}"
+					vm_id     = {{.TestContainerID}}
+					unprivileged = true
+					disk {
+						datastore_id = "local-lvm"
+						size         = 4
+					}
+					initialization {
+						hostname = "test-env-vars"
+						ip_config {
+							ipv4 {
+								address = "dhcp"
+							}
+						}
+					}
+					network_interface {
+						name = "vmbr0"
+					}
+					operating_system {
+						template_file_id = "local:vztmpl/{{.ImageFileName}}"
+						type             = "alpine"
+					}
+					environment_variables = {
+						FOO = "updated"
+						NEW_VAR = "new_value"
+					}
+				}`, WithRootUser()),
+				Check: resource.ComposeTestCheckFunc(
+					ResourceAttributes(accTestContainerName, map[string]string{
+						"environment_variables.FOO":     "updated",
+						"environment_variables.NEW_VAR": "new_value",
+						"environment_variables.%":       "2",
+					}),
+					func(*terraform.State) error {
+						ct := te.NodeClient().Container(accTestContainerID)
+						ctInfo, err := ct.GetContainer(t.Context())
+						require.NoError(te.t, err, "failed to get container")
+						require.NotNil(te.t, ctInfo.EnvironmentVariables, "environment_variables should not be nil")
+						require.Equal(te.t, "updated", (*ctInfo.EnvironmentVariables)["FOO"])
+						require.Equal(te.t, "new_value", (*ctInfo.EnvironmentVariables)["NEW_VAR"])
+						_, hasBaz := (*ctInfo.EnvironmentVariables)["BAZ"]
+						require.False(te.t, hasBaz, "BAZ should be removed")
+
+						return nil
+					},
+				),
+			},
+			{
+				Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_container" "test_container" {
+					node_name = "{{.NodeName}}"
+					vm_id     = {{.TestContainerID}}
+					unprivileged = true
+					disk {
+						datastore_id = "local-lvm"
+						size         = 4
+					}
+					initialization {
+						hostname = "test-env-vars"
+						ip_config {
+							ipv4 {
+								address = "dhcp"
+							}
+						}
+					}
+					network_interface {
+						name = "vmbr0"
+					}
+					operating_system {
+						template_file_id = "local:vztmpl/{{.ImageFileName}}"
+						type             = "alpine"
+					}
+				}`, WithRootUser()),
+				Check: resource.ComposeTestCheckFunc(
+					ResourceAttributes(accTestContainerName, map[string]string{
+						"environment_variables.%": "0",
+					}),
+					func(*terraform.State) error {
+						ct := te.NodeClient().Container(accTestContainerID)
+						ctInfo, err := ct.GetContainer(t.Context())
+						require.NoError(te.t, err, "failed to get container")
+						if ctInfo.EnvironmentVariables != nil {
+							require.Empty(te.t, *ctInfo.EnvironmentVariables, "environment_variables should be empty")
+						}
+
+						return nil
+					},
+				),
+			},
+		},
+	})
+}
+
 func testAccDownloadContainerTemplate(t *testing.T, te *Environment, imageFileName string) {
 	t.Helper()
 	err := te.NodeStorageClient().DownloadFileByURL(context.Background(), &storage.DownloadURLPostRequestBody{
diff --git a/proxmoxtf/resource/container/container_test.go b/proxmoxtf/resource/container/container_test.go
index 0aabf09c..275714d4 100644
--- a/proxmoxtf/resource/container/container_test.go
+++ b/proxmoxtf/resource/container/container_test.go
@@ -41,6 +41,7 @@ func TestContainerSchema(t *testing.T) {
 		mkCPU,
 		mkDescription,
 		mkDisk,
+		mkEnvironmentVariables,
 		mkInitialization,
 		mkHookScriptFileID,
 		mkMemory,
@@ -59,24 +60,25 @@ func TestContainerSchema(t *testing.T) {
 	})
 
 	test.AssertValueTypes(t, s, map[string]schema.ValueType{
-		mkCPU:               schema.TypeList,
-		mkDescription:       schema.TypeString,
-		mkDisk:              schema.TypeList,
-		mkInitialization:    schema.TypeList,
-		mkHookScriptFileID:  schema.TypeString,
-		mkMemory:            schema.TypeList,
-		mkDevicePassthrough: schema.TypeList,
-		mkMountPoint:        schema.TypeList,
-		mkOperatingSystem:   schema.TypeList,
-		mkPoolID:            schema.TypeString,
-		mkProtection:        schema.TypeBool,
-		mkStarted:           schema.TypeBool,
-		mkTags:              schema.TypeList,
-		mkTemplate:          schema.TypeBool,
-		mkUnprivileged:      schema.TypeBool,
-		mkStartOnBoot:       schema.TypeBool,
-		mkFeatures:          schema.TypeList,
-		mkVMID:              schema.TypeInt,
+		mkCPU:                  schema.TypeList,
+		mkDescription:          schema.TypeString,
+		mkDisk:                 schema.TypeList,
+		mkEnvironmentVariables: schema.TypeMap,
+		mkInitialization:       schema.TypeList,
+		mkHookScriptFileID:     schema.TypeString,
+		mkMemory:               schema.TypeList,
+		mkDevicePassthrough:    schema.TypeList,
+		mkMountPoint:           schema.TypeList,
+		mkOperatingSystem:      schema.TypeList,
+		mkPoolID:               schema.TypeString,
+		mkProtection:           schema.TypeBool,
+		mkStarted:              schema.TypeBool,
+		mkTags:                 schema.TypeList,
+		mkTemplate:             schema.TypeBool,
+		mkUnprivileged:         schema.TypeBool,
+		mkStartOnBoot:          schema.TypeBool,
+		mkFeatures:             schema.TypeList,
+		mkVMID:                 schema.TypeInt,
 	})
 
 	cloneSchema := test.AssertNestedSchemaExistence(t, s, mkClone)