diff --git a/docs/content/doc/usage/template-repositories.en-us.md b/docs/content/doc/usage/template-repositories.en-us.md
index 0c278648b3..5687861b8c 100644
--- a/docs/content/doc/usage/template-repositories.en-us.md
+++ b/docs/content/doc/usage/template-repositories.en-us.md
@@ -51,6 +51,8 @@ a/b/c/d.json
 
 In any file matched by the above globs, certain variables will be expanded.
 
+Matching filenames and paths can also be expanded, and are conservatively sanitized to support cross-platform filesystems.
+
 All variables must be of the form `$VAR` or `${VAR}`. To escape an expansion, use a double `$$`, such as `$$VAR` or `$${VAR}`
 
 | Variable             | Expands To                                          | Transformable |
diff --git a/modules/repository/generate.go b/modules/repository/generate.go
index 31d5ebbb11..102c5af1c9 100644
--- a/modules/repository/generate.go
+++ b/modules/repository/generate.go
@@ -11,6 +11,7 @@ import (
 	"os"
 	"path"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"time"
 
@@ -48,7 +49,7 @@ var defaultTransformers = []transformer{
 	{Name: "TITLE", Transform: util.ToTitleCase},
 }
 
-func generateExpansion(src string, templateRepo, generateRepo *repo_model.Repository) string {
+func generateExpansion(src string, templateRepo, generateRepo *repo_model.Repository, sanitizeFileName bool) string {
 	expansions := []expansion{
 		{Name: "REPO_NAME", Value: generateRepo.Name, Transformers: defaultTransformers},
 		{Name: "TEMPLATE_NAME", Value: templateRepo.Name, Transformers: defaultTransformers},
@@ -74,6 +75,9 @@ func generateExpansion(src string, templateRepo, generateRepo *repo_model.Reposi
 
 	return os.Expand(src, func(key string) string {
 		if expansion, ok := expansionMap[key]; ok {
+			if sanitizeFileName {
+				return fileNameSanitize(expansion)
+			}
 			return expansion
 		}
 		return key
@@ -191,10 +195,24 @@ func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *r
 						}
 
 						if err := os.WriteFile(path,
-							[]byte(generateExpansion(string(content), templateRepo, generateRepo)),
+							[]byte(generateExpansion(string(content), templateRepo, generateRepo, false)),
 							0o644); err != nil {
 							return err
 						}
+
+						substPath := filepath.FromSlash(filepath.Join(tmpDirSlash,
+							generateExpansion(base, templateRepo, generateRepo, true)))
+
+						// Create parent subdirectories if needed or continue silently if it exists
+						if err := os.MkdirAll(filepath.Dir(substPath), 0o755); err != nil {
+							return err
+						}
+
+						// Substitute filename variables
+						if err := os.Rename(path, substPath); err != nil {
+							return err
+						}
+
 						break
 					}
 				}
@@ -353,3 +371,13 @@ func GenerateRepository(ctx context.Context, doer, owner *user_model.User, templ
 
 	return generateRepo, nil
 }
+
+// Sanitize user input to valid OS filenames
+//
+//		Based on https://github.com/sindresorhus/filename-reserved-regex
+//	 Adds ".." to prevent directory traversal
+func fileNameSanitize(s string) string {
+	re := regexp.MustCompile(`(?i)\.\.|[<>:\"/\\|?*\x{0000}-\x{001F}]|^(con|prn|aux|nul|com\d|lpt\d)$`)
+
+	return strings.TrimSpace(re.ReplaceAllString(s, "_"))
+}
diff --git a/modules/repository/generate_test.go b/modules/repository/generate_test.go
index 1cb9a50f67..b0f97d0ffb 100644
--- a/modules/repository/generate_test.go
+++ b/modules/repository/generate_test.go
@@ -54,3 +54,14 @@ func TestGiteaTemplate(t *testing.T) {
 		})
 	}
 }
+
+func TestFileNameSanitize(t *testing.T) {
+	assert.Equal(t, "test_CON", fileNameSanitize("test_CON"))
+	assert.Equal(t, "test CON", fileNameSanitize("test CON "))
+	assert.Equal(t, "__traverse__", fileNameSanitize("../traverse/.."))
+	assert.Equal(t, "http___localhost_3003_user_test.git", fileNameSanitize("http://localhost:3003/user/test.git"))
+	assert.Equal(t, "_", fileNameSanitize("CON"))
+	assert.Equal(t, "_", fileNameSanitize("con"))
+	assert.Equal(t, "_", fileNameSanitize("\u0000"))
+	assert.Equal(t, "目标", fileNameSanitize("目标"))
+}
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/2a/83b349fa234131fc5db6f2a0498d3f4d3d6038 b/tests/gitea-repositories-meta/user27/template1.git/objects/2a/83b349fa234131fc5db6f2a0498d3f4d3d6038
new file mode 100644
index 0000000000..ab167ceeaf
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/2a/83b349fa234131fc5db6f2a0498d3f4d3d6038
@@ -0,0 +1,2 @@
+x��AJ�0�a�9�\@Ij2��C�w�"��h�i���޷q���~�{_�	����+c�)M���*rȉSD&��M��*�l�pm*��5fE_�P�8���D�QC�ɕa�o?��+\>���f۸����O��HH9G"x��{w��;��8
+i�s�������0��9�/�
IH
\ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/3d/0bc64f2521cfc7ffce6c175c1c846c88eb6df7 b/tests/gitea-repositories-meta/user27/template1.git/objects/3d/0bc64f2521cfc7ffce6c175c1c846c88eb6df7
new file mode 100644
index 0000000000..4912a5a99c
Binary files /dev/null and b/tests/gitea-repositories-meta/user27/template1.git/objects/3d/0bc64f2521cfc7ffce6c175c1c846c88eb6df7 differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/83/77b2196e99ac8635aae79df3db76959ccd1094 b/tests/gitea-repositories-meta/user27/template1.git/objects/83/77b2196e99ac8635aae79df3db76959ccd1094
new file mode 100644
index 0000000000..6538644ee8
Binary files /dev/null and b/tests/gitea-repositories-meta/user27/template1.git/objects/83/77b2196e99ac8635aae79df3db76959ccd1094 differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/99/45b93bcb5b70af06e0322bd2caa6180680991f b/tests/gitea-repositories-meta/user27/template1.git/objects/99/45b93bcb5b70af06e0322bd2caa6180680991f
new file mode 100644
index 0000000000..4af172516f
Binary files /dev/null and b/tests/gitea-repositories-meta/user27/template1.git/objects/99/45b93bcb5b70af06e0322bd2caa6180680991f differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/af/f5b10402b4e0479d1e76bc41a42d29fe7f28fa b/tests/gitea-repositories-meta/user27/template1.git/objects/af/f5b10402b4e0479d1e76bc41a42d29fe7f28fa
new file mode 100644
index 0000000000..5a80075eb1
Binary files /dev/null and b/tests/gitea-repositories-meta/user27/template1.git/objects/af/f5b10402b4e0479d1e76bc41a42d29fe7f28fa differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/b9/04864fd6cd0c8e9054351fd39a980bfd214229 b/tests/gitea-repositories-meta/user27/template1.git/objects/b9/04864fd6cd0c8e9054351fd39a980bfd214229
new file mode 100644
index 0000000000..b5d5d1d8dc
Binary files /dev/null and b/tests/gitea-repositories-meta/user27/template1.git/objects/b9/04864fd6cd0c8e9054351fd39a980bfd214229 differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/c5/10abf4c7c3e0dc4bf07db9344c61c4e6ee7cbc b/tests/gitea-repositories-meta/user27/template1.git/objects/c5/10abf4c7c3e0dc4bf07db9344c61c4e6ee7cbc
new file mode 100644
index 0000000000..d8ea1e1cd6
Binary files /dev/null and b/tests/gitea-repositories-meta/user27/template1.git/objects/c5/10abf4c7c3e0dc4bf07db9344c61c4e6ee7cbc differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/tests/gitea-repositories-meta/user27/template1.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
new file mode 100644
index 0000000000..7112238943
Binary files /dev/null and b/tests/gitea-repositories-meta/user27/template1.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/refs/heads/master b/tests/gitea-repositories-meta/user27/template1.git/refs/heads/master
index 0f13243bfd..bb42d472e5 100644
--- a/tests/gitea-repositories-meta/user27/template1.git/refs/heads/master
+++ b/tests/gitea-repositories-meta/user27/template1.git/refs/heads/master
@@ -1 +1 @@
-aacbdfe9e1c4b47f60abe81849045fa4e96f1d75
+2a83b349fa234131fc5db6f2a0498d3f4d3d6038
diff --git a/tests/integration/repo_generate_test.go b/tests/integration/repo_generate_test.go
index 4654fd70fa..961255cedf 100644
--- a/tests/integration/repo_generate_test.go
+++ b/tests/integration/repo_generate_test.go
@@ -7,16 +7,18 @@ import (
 	"fmt"
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"testing"
 
 	"code.gitea.io/gitea/models/unittest"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/tests"
 
 	"github.com/stretchr/testify/assert"
 )
 
-func testRepoGenerate(t *testing.T, session *TestSession, templateOwnerName, templateRepoName, generateOwnerName, generateRepoName string) *httptest.ResponseRecorder {
+func testRepoGenerate(t *testing.T, session *TestSession, templateID, templateOwnerName, templateRepoName, generateOwnerName, generateRepoName string) *httptest.ResponseRecorder {
 	generateOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: generateOwnerName})
 
 	// Step0: check the existence of the generated repo
@@ -41,16 +43,38 @@ func testRepoGenerate(t *testing.T, session *TestSession, templateOwnerName, tem
 	_, exists = htmlDoc.doc.Find(fmt.Sprintf(".owner.dropdown .item[data-value=\"%d\"]", generateOwner.ID)).Attr("data-value")
 	assert.True(t, exists, fmt.Sprintf("Generate owner '%s' is not present in select box", generateOwnerName))
 	req = NewRequestWithValues(t, "POST", link, map[string]string{
-		"_csrf":       htmlDoc.GetCSRF(),
-		"uid":         fmt.Sprintf("%d", generateOwner.ID),
-		"repo_name":   generateRepoName,
-		"git_content": "true",
+		"_csrf":         htmlDoc.GetCSRF(),
+		"uid":           fmt.Sprintf("%d", generateOwner.ID),
+		"repo_name":     generateRepoName,
+		"repo_template": templateID,
+		"git_content":   "true",
 	})
 	session.MakeRequest(t, req, http.StatusSeeOther)
 
 	// Step4: check the existence of the generated repo
 	req = NewRequestf(t, "GET", "/%s/%s", generateOwnerName, generateRepoName)
+	session.MakeRequest(t, req, http.StatusOK)
+
+	// Step5: check substituted values in Readme
+	req = NewRequestf(t, "GET", "/%s/%s/raw/branch/master/README.md", generateOwnerName, generateRepoName)
 	resp = session.MakeRequest(t, req, http.StatusOK)
+	body := fmt.Sprintf(`# %s Readme
+Owner: %s
+Link: /%s/%s
+Clone URL: %s%s/%s.git`,
+		generateRepoName,
+		strings.ToUpper(generateOwnerName),
+		generateOwnerName,
+		generateRepoName,
+		setting.AppURL,
+		generateOwnerName,
+		generateRepoName)
+	assert.Equal(t, body, resp.Body.String())
+
+	// Step6: check substituted values in substituted file path ${REPO_NAME}
+	req = NewRequestf(t, "GET", "/%s/%s/raw/branch/master/%s.log", generateOwnerName, generateRepoName, generateRepoName)
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	assert.Equal(t, generateRepoName, resp.Body.String())
 
 	return resp
 }
@@ -58,11 +82,11 @@ func testRepoGenerate(t *testing.T, session *TestSession, templateOwnerName, tem
 func TestRepoGenerate(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 	session := loginUser(t, "user1")
-	testRepoGenerate(t, session, "user27", "template1", "user1", "generated1")
+	testRepoGenerate(t, session, "44", "user27", "template1", "user1", "generated1")
 }
 
 func TestRepoGenerateToOrg(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 	session := loginUser(t, "user2")
-	testRepoGenerate(t, session, "user27", "template1", "user2", "generated2")
+	testRepoGenerate(t, session, "44", "user27", "template1", "user2", "generated2")
 }