diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go
index 803b73c968995..bdb2361239b5a 100644
--- a/models/packages/descriptor.go
+++ b/models/packages/descriptor.go
@@ -31,6 +31,7 @@ import (
 	"code.gitea.io/gitea/modules/packages/rpm"
 	"code.gitea.io/gitea/modules/packages/rubygems"
 	"code.gitea.io/gitea/modules/packages/swift"
+	"code.gitea.io/gitea/modules/packages/terraform"
 	"code.gitea.io/gitea/modules/packages/vagrant"
 	"code.gitea.io/gitea/modules/util"
 
@@ -191,6 +192,8 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc
 		metadata = &rubygems.Metadata{}
 	case TypeSwift:
 		metadata = &swift.Metadata{}
+	case TypeTerraform:
+		metadata = &terraform.Metadata{}
 	case TypeVagrant:
 		metadata = &vagrant.Metadata{}
 	default:
diff --git a/models/packages/package.go b/models/packages/package.go
index 31e1277a6e37b..ab5deeaec161d 100644
--- a/models/packages/package.go
+++ b/models/packages/package.go
@@ -51,6 +51,7 @@ const (
 	TypeRpm       Type = "rpm"
 	TypeRubyGems  Type = "rubygems"
 	TypeSwift     Type = "swift"
+	TypeTerraform Type = "terraform"
 	TypeVagrant   Type = "vagrant"
 )
 
@@ -76,6 +77,7 @@ var TypeList = []Type{
 	TypeRpm,
 	TypeRubyGems,
 	TypeSwift,
+	TypeTerraform,
 	TypeVagrant,
 }
 
@@ -124,6 +126,8 @@ func (pt Type) Name() string {
 		return "RubyGems"
 	case TypeSwift:
 		return "Swift"
+	case TypeTerraform:
+		return "Terraform"
 	case TypeVagrant:
 		return "Vagrant"
 	}
@@ -175,6 +179,8 @@ func (pt Type) SVGName() string {
 		return "gitea-rubygems"
 	case TypeSwift:
 		return "gitea-swift"
+	case TypeTerraform:
+		return "gitea-terraform"
 	case TypeVagrant:
 		return "gitea-vagrant"
 	}
diff --git a/modules/packages/terraform/metadata.go b/modules/packages/terraform/metadata.go
new file mode 100644
index 0000000000000..6dfc0d66cee1b
--- /dev/null
+++ b/modules/packages/terraform/metadata.go
@@ -0,0 +1,88 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package terraform
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"errors"
+	"io"
+
+	"code.gitea.io/gitea/modules/json"
+)
+
+const (
+	PropertyTerraformState = "terraform.state"
+)
+
+// Metadata represents the Terraform backend metadata
+// Updated to align with TerraformState structure
+// Includes additional metadata fields like Description, Author, and URLs
+type Metadata struct {
+	Version          int             `json:"version"`
+	TerraformVersion string          `json:"terraform_version,omitempty"`
+	Serial           uint64          `json:"serial"`
+	Lineage          string          `json:"lineage"`
+	Outputs          map[string]any  `json:"outputs,omitempty"`
+	Resources        []ResourceState `json:"resources,omitempty"`
+	Description      string          `json:"description,omitempty"`
+	Author           string          `json:"author,omitempty"`
+	ProjectURL       string          `json:"project_url,omitempty"`
+	RepositoryURL    string          `json:"repository_url,omitempty"`
+}
+
+// ResourceState represents the state of a resource
+type ResourceState struct {
+	Mode      string          `json:"mode"`
+	Type      string          `json:"type"`
+	Name      string          `json:"name"`
+	Provider  string          `json:"provider"`
+	Instances []InstanceState `json:"instances"`
+}
+
+// InstanceState represents the state of a resource instance
+type InstanceState struct {
+	SchemaVersion int            `json:"schema_version"`
+	Attributes    map[string]any `json:"attributes"`
+}
+
+// ParseMetadataFromState retrieves metadata from the archive with Terraform state
+func ParseMetadataFromState(r io.Reader) (*Metadata, error) {
+	gzr, err := gzip.NewReader(r)
+	if err != nil {
+		return nil, err
+	}
+	defer gzr.Close()
+
+	tr := tar.NewReader(gzr)
+	for {
+		hd, err := tr.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+
+		if hd.Typeflag != tar.TypeReg {
+			continue
+		}
+
+		// Looking for the state.json file
+		if hd.Name == "state.json" {
+			return ParseStateFile(tr)
+		}
+	}
+
+	return nil, errors.New("state.json not found in archive")
+}
+
+// ParseStateFile parses the state.json file and returns Terraform metadata
+func ParseStateFile(r io.Reader) (*Metadata, error) {
+	var stateData Metadata
+	if err := json.NewDecoder(r).Decode(&stateData); err != nil {
+		return nil, err
+	}
+	return &stateData, nil
+}
diff --git a/modules/packages/terraform/metadata_test.go b/modules/packages/terraform/metadata_test.go
new file mode 100644
index 0000000000000..657c32588d428
--- /dev/null
+++ b/modules/packages/terraform/metadata_test.go
@@ -0,0 +1,161 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package terraform
+
+import (
+	"archive/tar"
+	"bytes"
+	"compress/gzip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// TestParseMetadataFromState tests the ParseMetadataFromState function
+func TestParseMetadataFromState(t *testing.T) {
+	tests := []struct {
+		name          string
+		input         []byte
+		expectedError bool
+	}{
+		{
+			name:          "valid state file",
+			input:         createValidStateArchive(),
+			expectedError: false,
+		},
+		{
+			name:          "missing state.json file",
+			input:         createInvalidStateArchive(),
+			expectedError: true,
+		},
+		{
+			name:          "corrupt archive",
+			input:         []byte("invalid archive data"),
+			expectedError: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := bytes.NewReader(tt.input)
+			metadata, err := ParseMetadataFromState(r)
+
+			if tt.expectedError {
+				assert.Error(t, err)
+				assert.Nil(t, metadata)
+			} else {
+				assert.NoError(t, err)
+				assert.NotNil(t, metadata)
+				// Optionally, check if certain fields are populated correctly
+				assert.NotEmpty(t, metadata.Lineage)
+			}
+		})
+	}
+}
+
+// createValidStateArchive creates a valid TAR.GZ archive with a sample state.json
+func createValidStateArchive() []byte {
+	metadata := `{
+		"version": 4,
+		"terraform_version": "1.2.0",
+		"serial": 1,
+		"lineage": "abc123",
+		"resources": [],
+		"description": "Test project",
+		"author": "Test Author",
+		"project_url": "http://example.com",
+		"repository_url": "http://repo.com"
+	}`
+
+	// Create a gzip writer and tar writer
+	buf := new(bytes.Buffer)
+	gz := gzip.NewWriter(buf)
+	tw := tar.NewWriter(gz)
+
+	// Add the state.json file to the tar
+	hdr := &tar.Header{
+		Name: "state.json",
+		Size: int64(len(metadata)),
+		Mode: 0o600,
+	}
+	if err := tw.WriteHeader(hdr); err != nil {
+		panic(err)
+	}
+	if _, err := tw.Write([]byte(metadata)); err != nil {
+		panic(err)
+	}
+
+	// Close the writers
+	if err := tw.Close(); err != nil {
+		panic(err)
+	}
+	if err := gz.Close(); err != nil {
+		panic(err)
+	}
+
+	return buf.Bytes()
+}
+
+// createInvalidStateArchive creates an invalid TAR.GZ archive (missing state.json)
+func createInvalidStateArchive() []byte {
+	// Create a tar archive without the state.json file
+	buf := new(bytes.Buffer)
+	gz := gzip.NewWriter(buf)
+	tw := tar.NewWriter(gz)
+
+	// Add an empty file to the tar (but not state.json)
+	hdr := &tar.Header{
+		Name: "other_file.txt",
+		Size: 0,
+		Mode: 0o600,
+	}
+	if err := tw.WriteHeader(hdr); err != nil {
+		panic(err)
+	}
+
+	// Close the writers
+	if err := tw.Close(); err != nil {
+		panic(err)
+	}
+	if err := gz.Close(); err != nil {
+		panic(err)
+	}
+
+	return buf.Bytes()
+}
+
+// TestParseStateFile tests the ParseStateFile function directly
+func TestParseStateFile(t *testing.T) {
+	tests := []struct {
+		name          string
+		input         string
+		expectedError bool
+	}{
+		{
+			name:          "valid state.json",
+			input:         `{"version":4,"terraform_version":"1.2.0","serial":1,"lineage":"abc123"}`,
+			expectedError: false,
+		},
+		{
+			name:          "invalid JSON",
+			input:         `{"version":4,"terraform_version"}`,
+			expectedError: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := bytes.NewReader([]byte(tt.input))
+			metadata, err := ParseStateFile(r)
+
+			if tt.expectedError {
+				assert.Error(t, err)
+				assert.Nil(t, metadata)
+			} else {
+				assert.NoError(t, err)
+				assert.NotNil(t, metadata)
+			}
+		})
+	}
+}
diff --git a/modules/setting/packages.go b/modules/setting/packages.go
index 3f618cfd64115..6eff4f1b5c9eb 100644
--- a/modules/setting/packages.go
+++ b/modules/setting/packages.go
@@ -42,6 +42,7 @@ var (
 		LimitSizeRpm         int64
 		LimitSizeRubyGems    int64
 		LimitSizeSwift       int64
+		LimitSizeTerraform   int64
 		LimitSizeVagrant     int64
 
 		DefaultRPMSignEnabled bool
@@ -100,6 +101,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) (err error) {
 	Packages.LimitSizeRpm = mustBytes(sec, "LIMIT_SIZE_RPM")
 	Packages.LimitSizeRubyGems = mustBytes(sec, "LIMIT_SIZE_RUBYGEMS")
 	Packages.LimitSizeSwift = mustBytes(sec, "LIMIT_SIZE_SWIFT")
+	Packages.LimitSizeTerraform = mustBytes(sec, "LIMIT_SIZE_TERRAFORM")
 	Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT")
 	Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false)
 	return nil
diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go
index 41c3eb95e9011..2dd53198d515e 100644
--- a/routers/api/packages/api.go
+++ b/routers/api/packages/api.go
@@ -34,6 +34,7 @@ import (
 	"code.gitea.io/gitea/routers/api/packages/rpm"
 	"code.gitea.io/gitea/routers/api/packages/rubygems"
 	"code.gitea.io/gitea/routers/api/packages/swift"
+	"code.gitea.io/gitea/routers/api/packages/terraform"
 	"code.gitea.io/gitea/routers/api/packages/vagrant"
 	"code.gitea.io/gitea/services/auth"
 	"code.gitea.io/gitea/services/context"
@@ -674,6 +675,26 @@ func CommonRoutes() *web.Router {
 				})
 			})
 		}, reqPackageAccess(perm.AccessModeRead))
+		// Define routes for Terraform HTTP backend API
+		r.Group("/terraform/state", func() {
+			// Routes for specific state identified by {statename}
+			r.Group("/{statename}", func() {
+				// Fetch the current state
+				r.Get("", reqPackageAccess(perm.AccessModeRead), terraform.GetState)
+				// Update the state (supports both POST and PUT methods)
+				r.Post("", reqPackageAccess(perm.AccessModeWrite), terraform.UpdateState)
+				r.Put("", reqPackageAccess(perm.AccessModeWrite), terraform.UpdateState)
+				// Delete the state
+				r.Delete("", reqPackageAccess(perm.AccessModeWrite), terraform.DeleteState)
+				// Lock and unlock operations for the state
+				r.Group("/lock", func() {
+					// Lock the state
+					r.Post("", reqPackageAccess(perm.AccessModeWrite), terraform.LockState)
+					// Unlock the state
+					r.Delete("", reqPackageAccess(perm.AccessModeWrite), terraform.UnlockState)
+				})
+			})
+		}, reqPackageAccess(perm.AccessModeRead))
 	}, context.UserAssignmentWeb(), context.PackageAssignment())
 
 	return r
diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go
new file mode 100644
index 0000000000000..61cd4c27c39c6
--- /dev/null
+++ b/routers/api/packages/terraform/terraform.go
@@ -0,0 +1,278 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package terraform
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+	"time"
+
+	packages_model "code.gitea.io/gitea/models/packages"
+	"code.gitea.io/gitea/modules/json"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/optional"
+	packages_module "code.gitea.io/gitea/modules/packages"
+	"code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/packages"
+
+	"github.com/google/uuid"
+)
+
+type TFState struct {
+	Version          int             `json:"version"`
+	TerraformVersion string          `json:"terraform_version"`
+	Serial           uint64          `json:"serial"`
+	Lineage          string          `json:"lineage"`
+	Outputs          map[string]any  `json:"outputs"`
+	Resources        []ResourceState `json:"resources"`
+}
+
+type ResourceState struct {
+	Mode      string          `json:"mode"`
+	Type      string          `json:"type"`
+	Name      string          `json:"name"`
+	Provider  string          `json:"provider"`
+	Instances []InstanceState `json:"instances"`
+}
+
+type InstanceState struct {
+	SchemaVersion int            `json:"schema_version"`
+	Attributes    map[string]any `json:"attributes"`
+}
+
+type LockInfo struct {
+	ID      string `json:"id"`
+	Created string `json:"created"`
+}
+
+var stateLocks = make(map[string]LockInfo)
+
+func apiError(ctx *context.Context, status int, message string) {
+	log.Error("Terraform API Error: %d - %s", status, message)
+	ctx.JSON(status, map[string]string{"error": message})
+}
+
+func getLockID(ctx *context.Context) (string, error) {
+	var lock struct {
+		ID string `json:"ID"`
+	}
+
+	// Read the body of the request and try to parse the JSON
+	body, err := io.ReadAll(ctx.Req.Body)
+	if err == nil && len(body) > 0 {
+		if err := json.Unmarshal(body, &lock); err != nil {
+			log.Error("Failed to unmarshal request body: %v", err)
+			return "", err
+		}
+	}
+
+	// We check the presence of lock ID in the request body or request parameters
+	if lock.ID == "" {
+		lock.ID = ctx.Req.URL.Query().Get("ID")
+	}
+
+	if lock.ID == "" {
+		apiError(ctx, http.StatusBadRequest, "Missing lock ID")
+		return "", fmt.Errorf("missing lock ID")
+	}
+
+	log.Info("Extracted lockID: %s", lock.ID)
+	return lock.ID, nil
+}
+
+func GetState(ctx *context.Context) {
+	stateName := ctx.PathParam("statename")
+	log.Info("GetState called for: %s", stateName)
+
+	pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+		OwnerID:         ctx.Package.Owner.ID,
+		Type:            packages_model.TypeTerraform,
+		Name:            packages_model.SearchValue{ExactMatch: true, Value: stateName},
+		HasFileWithName: stateName,
+		IsInternal:      optional.Some(false),
+		Sort:            packages_model.SortCreatedDesc,
+	})
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, "Failed to fetch latest versions")
+		return
+	}
+
+	if len(pvs) == 0 {
+		apiError(ctx, http.StatusNoContent, "No content available")
+		return
+	}
+
+	stream, _, _, err := packages.GetFileStreamByPackageNameAndVersion(ctx, &packages.PackageInfo{
+		Owner:       ctx.Package.Owner,
+		PackageType: packages_model.TypeTerraform,
+		Name:        stateName,
+		Version:     pvs[0].Version,
+	}, &packages.PackageFileInfo{Filename: stateName})
+	if err != nil {
+		switch {
+		case errors.Is(err, packages_model.ErrPackageNotExist):
+			apiError(ctx, http.StatusNotFound, "Package not found")
+		case errors.Is(err, packages_model.ErrPackageFileNotExist):
+			apiError(ctx, http.StatusNotFound, "File not found")
+		default:
+			apiError(ctx, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+	defer stream.Close()
+
+	var state TFState
+	if err := json.NewDecoder(stream).Decode(&state); err != nil {
+		apiError(ctx, http.StatusInternalServerError, "Failed to parse state file")
+		return
+	}
+
+	if state.Lineage == "" {
+		state.Lineage = uuid.NewString()
+		log.Info("Generated new lineage for state: %s", state.Lineage)
+	}
+
+	ctx.Resp.Header().Set("Content-Type", "application/json")
+	ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", stateName))
+	ctx.JSON(http.StatusOK, state)
+}
+
+func UpdateState(ctx *context.Context) {
+	stateName := ctx.PathParam("statename")
+	body, err := io.ReadAll(ctx.Req.Body)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, "Failed to read request body")
+		return
+	}
+
+	var newState TFState
+	if err := json.Unmarshal(body, &newState); err != nil {
+		apiError(ctx, http.StatusBadRequest, "Invalid JSON")
+		return
+	}
+
+	pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+		OwnerID:         ctx.Package.Owner.ID,
+		Type:            packages_model.TypeTerraform,
+		Name:            packages_model.SearchValue{ExactMatch: true, Value: stateName},
+		HasFileWithName: stateName,
+		IsInternal:      optional.Some(false),
+		Sort:            packages_model.SortCreatedDesc,
+	})
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err.Error())
+		return
+	}
+	serial := uint64(0)
+	if len(pvs) > 0 {
+		if lastSerial, err := strconv.ParseUint(pvs[0].Version, 10, 64); err == nil {
+			serial = lastSerial + 1
+		}
+	}
+
+	packageVersion := fmt.Sprintf("%d", serial)
+
+	packageInfo := &packages.PackageCreationInfo{
+		PackageInfo: packages.PackageInfo{
+			Owner:       ctx.Package.Owner,
+			PackageType: packages_model.TypeTerraform,
+			Name:        stateName,
+			Version:     packageVersion,
+		},
+		Creator:  ctx.Doer,
+		Metadata: newState,
+	}
+
+	buffer, err := packages_module.CreateHashedBufferFromReader(bytes.NewReader(body))
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, "Failed to create buffer")
+		return
+	}
+	_, _, err = packages.CreatePackageOrAddFileToExisting(ctx, packageInfo, &packages.PackageFileCreationInfo{
+		PackageFileInfo: packages.PackageFileInfo{Filename: stateName},
+		Creator:         ctx.Doer,
+		Data:            buffer,
+		IsLead:          true,
+	})
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, "Failed to update package")
+		return
+	}
+
+	ctx.JSON(http.StatusOK, map[string]string{"message": "State updated successfully", "statename": stateName})
+}
+
+func LockState(ctx *context.Context) {
+	stateName := ctx.PathParam("statename")
+	lockID, err := getLockID(ctx)
+	if err != nil {
+		apiError(ctx, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	// Check if the state is locked
+	if lockInfo, locked := stateLocks[stateName]; locked {
+		log.Warn("State %s is already locked", stateName)
+
+		// Generate a response for the conflict with information about the current lock
+		response := lockInfo // Return full information about the lock
+		ctx.JSON(http.StatusConflict, response)
+		return
+	}
+
+	// Set the lock
+	stateLocks[stateName] = LockInfo{
+		ID:      lockID,
+		Created: time.Now().UTC().Format(time.RFC3339),
+	}
+
+	log.Info("Locked state: %s with ID: %s", stateName, lockID)
+	ctx.JSON(http.StatusOK, map[string]string{"message": "State locked successfully", "statename": stateName})
+}
+
+func UnlockState(ctx *context.Context) {
+	stateName := ctx.PathParam("statename")
+	lockID, err := getLockID(ctx)
+	if err != nil {
+		apiError(ctx, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	// Check the lock status
+	currentLockInfo, locked := stateLocks[stateName]
+	if !locked || currentLockInfo.ID != lockID {
+		log.Warn("Unlock attempt failed for state %s with lock ID %s", stateName, lockID)
+		apiError(ctx, http.StatusConflict, fmt.Sprintf("State %s is not locked or lock ID mismatch", stateName))
+		return
+	}
+
+	// Remove the lock
+	delete(stateLocks, stateName)
+	log.Info("Unlocked state: %s with ID: %s", stateName, lockID)
+	ctx.JSON(http.StatusOK, map[string]string{"message": "State unlocked successfully"})
+}
+
+func DeleteState(ctx *context.Context) {
+	stateName := ctx.PathParam("statename")
+	pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, stateName)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, "Failed to fetch package versions")
+		return
+	}
+	if len(pvs) == 0 {
+		ctx.Status(http.StatusNoContent)
+		return
+	}
+	for _, pv := range pvs {
+		if err := packages.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
+			apiError(ctx, http.StatusInternalServerError, "Failed to delete package version")
+			return
+		}
+	}
+	ctx.JSON(http.StatusOK, map[string]string{"message": "State deleted successfully"})
+}
diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go
index b38aa131676e1..c9af7e8db0aad 100644
--- a/routers/api/v1/packages/package.go
+++ b/routers/api/v1/packages/package.go
@@ -40,7 +40,7 @@ func ListPackages(ctx *context.APIContext) {
 	//   in: query
 	//   description: package type filter
 	//   type: string
-	//   enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, vagrant]
+	//   enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, terraform, vagrant]
 	// - name: q
 	//   in: query
 	//   description: name filter
diff --git a/services/forms/package_form.go b/services/forms/package_form.go
index 9b6f9071647bc..d1a2b8587ccf5 100644
--- a/services/forms/package_form.go
+++ b/services/forms/package_form.go
@@ -15,7 +15,7 @@ import (
 type PackageCleanupRuleForm struct {
 	ID            int64
 	Enabled       bool
-	Type          string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,vagrant)"`
+	Type          string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,terraform,vagrant)"`
 	KeepCount     int    `binding:"In(0,1,5,10,25,50,100)"`
 	KeepPattern   string `binding:"RegexPattern"`
 	RemoveDays    int    `binding:"In(0,7,14,30,60,90,180)"`
diff --git a/services/packages/packages.go b/services/packages/packages.go
index bd1d460fd3ba8..0736ef4b56e2d 100644
--- a/services/packages/packages.go
+++ b/services/packages/packages.go
@@ -393,6 +393,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p
 		typeSpecificSize = setting.Packages.LimitSizeRubyGems
 	case packages_model.TypeSwift:
 		typeSpecificSize = setting.Packages.LimitSizeSwift
+	case packages_model.TypeTerraform:
+		typeSpecificSize = setting.Packages.LimitSizeTerraform
 	case packages_model.TypeVagrant:
 		typeSpecificSize = setting.Packages.LimitSizeVagrant
 	}
diff --git a/templates/package/content/terraform.tmpl b/templates/package/content/terraform.tmpl
new file mode 100644
index 0000000000000..c59713c0dbc0c
--- /dev/null
+++ b/templates/package/content/terraform.tmpl
@@ -0,0 +1,30 @@
+{{if eq .PackageDescriptor.Package.Type "terraform"}}
+	<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.installation"}}</h4>
+	<div class="ui attached segment">
+		<div class="ui form">
+			<div class="field">
+				<label>{{svg "octicon-terminal"}} {{ctx.Locale.Tr "packages.terraform.install"}}</label>
+				<div class="markup"><pre class="code-block"><code>
+export GITEA_USER_PASSWORD=&lt;YOUR-USER-PASSWORD&gt;
+export TF_STATE_NAME=your-state.tfstate
+terraform init \
+&ensp;-backend-config="address=<origin-url data-url="{{AppSubUrl}}/api/packages/{{.PackageDescriptor.Owner.Name}}/terraform/state/$TF_STATE_NAME"></origin-url> \
+&ensp;-backend-config="lock_address=<origin-url data-url="{{AppSubUrl}}/api/packages/{{.PackageDescriptor.Owner.Name}}/terraform/state/$TF_STATE_NAME/lock"></origin-url> \
+&ensp;-backend-config="unlock_address=<origin-url data-url="{{AppSubUrl}}/api/packages/{{.PackageDescriptor.Owner.Name}}/terraform/state/$TF_STATE_NAME/lock"></origin-url> \
+&ensp;-backend-config="username={{.PackageDescriptor.Owner.Name}}" \
+&ensp;-backend-config="password=$GITEA_USER_PASSWORD" \
+&ensp;-backend-config="lock_method=POST" \
+&ensp;-backend-config="unlock_method=DELETE" \
+&ensp;-backend-config="retry_wait_min=5"
+</code></pre></div>
+			</div>
+			<div class="field">
+				<label>{{ctx.Locale.Tr "packages.registry.documentation" "Terraform" "https://docs.gitea.com/usage/packages/terraform/"}}</label>
+			</div>
+		</div>
+	</div>
+	{{if .PackageDescriptor.Metadata.Description}}
+		<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.about"}}</h4>
+		<div class="ui attached segment">{{.PackageDescriptor.Metadata.Description}}</div>
+	{{end}}
+{{end}}
diff --git a/templates/package/metadata/terraform.tmpl b/templates/package/metadata/terraform.tmpl
new file mode 100644
index 0000000000000..87fdf2c2f9404
--- /dev/null
+++ b/templates/package/metadata/terraform.tmpl
@@ -0,0 +1,5 @@
+{{if eq .PackageDescriptor.Package.Type "terrafomr"}}
+	{{if .PackageDescriptor.Metadata.Author}}<div class="item" title="{{ctx.Locale.Tr "packages.details.author"}}">{{svg "octicon-person"}} {{.PackageDescriptor.Metadata.Author}}</div>{{end}}
+	{{if .PackageDescriptor.Metadata.ProjectURL}}<div class="item">{{svg "octicon-link-external"}} <a href="{{.PackageDescriptor.Metadata.ProjectURL}}" target="_blank" rel="noopener noreferrer me">{{ctx.Locale.Tr "packages.details.project_site"}}</a></div>{{end}}
+	{{if .PackageDescriptor.Metadata.RepositoryURL}}<div class="item">{{svg "octicon-link-external"}} <a href="{{.PackageDescriptor.Metadata.RepositoryURL}}" target="_blank" rel="noopener noreferrer me">{{ctx.Locale.Tr "packages.details.repository_site"}}</a></div>{{end}}
+{{end}}
diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl
index 9e92207466d96..5c5305cd09d8a 100644
--- a/templates/package/view.tmpl
+++ b/templates/package/view.tmpl
@@ -37,6 +37,7 @@
 				{{template "package/content/rpm" .}}
 				{{template "package/content/rubygems" .}}
 				{{template "package/content/swift" .}}
+				{{template "package/content/terraform" .}}
 				{{template "package/content/vagrant" .}}
 			</div>
 			<div class="issue-content-right ui segment">
@@ -68,6 +69,7 @@
 					{{template "package/metadata/rpm" .}}
 					{{template "package/metadata/rubygems" .}}
 					{{template "package/metadata/swift" .}}
+					{{template "package/metadata/terraform" .}}
 					{{template "package/metadata/vagrant" .}}
 					{{if not (and (eq .PackageDescriptor.Package.Type "container") .PackageDescriptor.Metadata.Manifests)}}
 					<div class="item">{{svg "octicon-database"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}</div>
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 8082fc594ac02..17cb8c1cc9f20 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -3275,6 +3275,7 @@
               "rpm",
               "rubygems",
               "swift",
+              "terraform",
               "vagrant"
             ],
             "type": "string",
diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go
new file mode 100644
index 0000000000000..424b4034617ca
--- /dev/null
+++ b/tests/integration/api_packages_terraform_test.go
@@ -0,0 +1,130 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+
+	"code.gitea.io/gitea/models/auth"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/json"
+	"code.gitea.io/gitea/tests"
+
+	gouuid "github.com/google/uuid"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPackageTerraform(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+	// Get token for the user
+	token := "Bearer " + getUserToken(t, user.Name, auth.AccessTokenScopeWritePackage)
+
+	// Define important values
+	lineage := "bca3c5f6-01dc-cdad-5310-d1b12e02e430"
+	terraformVersion := "1.10.4"
+	serial := float64(1)
+	resourceName := "hello"
+	resourceType := "null_resource"
+	id := gouuid.New().String() // Generate a unique ID
+
+	// Build the state JSON
+	buildState := func() string {
+		return `{
+			"version": 4,
+			"terraform_version": "` + terraformVersion + `",
+			"serial": ` + fmt.Sprintf("%.0f", serial) + `,
+			"lineage": "` + lineage + `",
+			"outputs": {},
+			"resources": [{
+				"mode": "managed",
+				"type": "` + resourceType + `",
+				"name": "` + resourceName + `",
+				"provider": "provider[\"registry.terraform.io/hashicorp/null\"]",
+				"instances": [{
+					"schema_version": 0,
+					"attributes": {
+						"id": "3832416504545530133",
+						"triggers": null
+					},
+					"sensitive_attributes": []
+				}]
+			}],
+			"check_results": null
+		}`
+	}
+	state := buildState()
+	content := []byte(state)
+	root := fmt.Sprintf("/api/packages/%s/terraform/state", user.Name)
+	stateURL := fmt.Sprintf("%s/providers-gitea.tfstate", root)
+
+	// Upload test
+	t.Run("Upload", func(t *testing.T) {
+		uploadURL := fmt.Sprintf("%s?ID=%s", stateURL, id)
+		req := NewRequestWithBody(t, "POST", uploadURL, bytes.NewReader(content)).AddTokenAuth(token)
+		resp := MakeRequest(t, req, http.StatusOK) // Expecting 200 OK
+		assert.Equal(t, http.StatusOK, resp.Code)
+		assert.Contains(t, resp.Header().Get("Content-Type"), "application/json")
+		bodyBytes, err := io.ReadAll(resp.Body)
+		require.NoError(t, err)
+		require.NotEmpty(t, bodyBytes)
+	})
+
+	// Download test
+	t.Run("Download", func(t *testing.T) {
+		downloadURL := fmt.Sprintf("%s?ID=%s", stateURL, id)
+		req := NewRequest(t, "GET", downloadURL)
+		resp := MakeRequest(t, req, http.StatusOK)
+		assert.True(t, strings.HasPrefix(resp.Header().Get("Content-Type"), "application/json"))
+
+		bodyBytes, err := io.ReadAll(resp.Body)
+		require.NoError(t, err)
+		require.NotEmpty(t, bodyBytes)
+
+		var jsonResponse map[string]any
+		err = json.Unmarshal(bodyBytes, &jsonResponse)
+		require.NoError(t, err)
+
+		// Validate the response
+		assert.Equal(t, lineage, jsonResponse["lineage"])
+		assert.Equal(t, terraformVersion, jsonResponse["terraform_version"])
+		assert.InEpsilon(t, serial, jsonResponse["serial"].(float64), 0.0001)
+		resource := jsonResponse["resources"].([]any)[0].(map[string]any)
+		assert.Equal(t, resourceName, resource["name"])
+		assert.Equal(t, resourceType, resource["type"])
+		assert.NotContains(t, resource, "sensitive_attributes")
+	})
+
+	// Lock state test
+	t.Run("LockState", func(t *testing.T) {
+		lockURL := fmt.Sprintf("%s/lock?ID=%s", stateURL, id)
+		req := NewRequestWithBody(t, "POST", lockURL, bytes.NewReader(content)).AddTokenAuth(token)
+		resp := MakeRequest(t, req, http.StatusOK) // Expecting 200 OK
+		assert.Equal(t, http.StatusOK, resp.Code)
+	})
+
+	// Unlock state test
+	t.Run("UnlockState", func(t *testing.T) {
+		unlockURL := fmt.Sprintf("%s/lock?ID=%s", stateURL, id)
+		req := NewRequestWithBody(t, "DELETE", unlockURL, bytes.NewReader(content)).AddTokenAuth(token)
+		resp := MakeRequest(t, req, http.StatusOK) // Expecting 200 OK
+		assert.Equal(t, http.StatusOK, resp.Code)
+	})
+
+	// Download not found test
+	t.Run("DownloadNotFound", func(t *testing.T) {
+		invalidStateURL := fmt.Sprintf("%s/invalid-state.tfstate?ID=%s", root, id)
+		req := NewRequest(t, "GET", invalidStateURL)
+		resp := MakeRequest(t, req, http.StatusNoContent) // Expecting 204 No Content
+		assert.Equal(t, http.StatusNoContent, resp.Code)
+	})
+}