diff --git a/modules/actions/log.go b/modules/actions/log.go
index 36bed931fa..cdf18646aa 100644
--- a/modules/actions/log.go
+++ b/modules/actions/log.go
@@ -73,7 +73,7 @@ func WriteLogs(ctx context.Context, filename string, offset int64, rows []*runne
 }
 
 func ReadLogs(ctx context.Context, inStorage bool, filename string, offset, limit int64) ([]*runnerv1.LogRow, error) {
-	f, err := openLogs(ctx, inStorage, filename)
+	f, err := OpenLogs(ctx, inStorage, filename)
 	if err != nil {
 		return nil, err
 	}
@@ -141,7 +141,7 @@ func RemoveLogs(ctx context.Context, inStorage bool, filename string) error {
 	return nil
 }
 
-func openLogs(ctx context.Context, inStorage bool, filename string) (io.ReadSeekCloser, error) {
+func OpenLogs(ctx context.Context, inStorage bool, filename string) (io.ReadSeekCloser, error) {
 	if !inStorage {
 		name := DBFSPrefix + filename
 		f, err := dbfs.Open(ctx, name)
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 6de0258051..be90e5366c 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -129,6 +129,7 @@ concept_user_organization = Organization
 show_timestamps = Show timestamps
 show_log_seconds = Show seconds
 show_full_screen = Show full screen
+download_logs = Download logs
 
 confirm_delete_selected = Confirm to delete all selected items?
 
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index 7c2e9d63d6..537bc61807 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -8,6 +8,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"strings"
 	"time"
 
 	actions_model "code.gitea.io/gitea/models/actions"
@@ -310,6 +311,55 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob) erro
 	return nil
 }
 
+func Logs(ctx *context_module.Context) {
+	runIndex := ctx.ParamsInt64("run")
+	jobIndex := ctx.ParamsInt64("job")
+
+	job, _ := getRunJobs(ctx, runIndex, jobIndex)
+	if ctx.Written() {
+		return
+	}
+	if job.TaskID == 0 {
+		ctx.Error(http.StatusNotFound, "job is not started")
+		return
+	}
+
+	err := job.LoadRun(ctx)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	task, err := actions_model.GetTaskByID(ctx, job.TaskID)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, err.Error())
+		return
+	}
+	if task.LogExpired {
+		ctx.Error(http.StatusNotFound, "logs have been cleaned up")
+		return
+	}
+
+	reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, err.Error())
+		return
+	}
+	defer reader.Close()
+
+	workflowName := job.Run.WorkflowID
+	if p := strings.Index(workflowName, "."); p > 0 {
+		workflowName = workflowName[0:p]
+	}
+	ctx.ServeContent(reader, &context_module.ServeHeaderOptions{
+		Filename:           fmt.Sprintf("%v-%v-%v.log", workflowName, job.Name, task.ID),
+		ContentLength:      &task.LogSize,
+		ContentType:        "text/plain",
+		ContentTypeCharset: "utf-8",
+		Disposition:        "attachment",
+	})
+}
+
 func Cancel(ctx *context_module.Context) {
 	runIndex := ctx.ParamsInt64("run")
 
diff --git a/routers/web/web.go b/routers/web/web.go
index 26ad2d54c3..a5465eb041 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1207,6 +1207,7 @@ func registerRoutes(m *web.Route) {
 						Get(actions.View).
 						Post(web.Bind(actions.ViewRequest{}), actions.ViewPost)
 					m.Post("/rerun", reqRepoActionsWriter, actions.RerunOne)
+					m.Get("/logs", actions.Logs)
 				})
 				m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
 				m.Post("/approve", reqRepoActionsWriter, actions.Approve)
diff --git a/templates/repo/actions/view.tmpl b/templates/repo/actions/view.tmpl
index 297232fca0..7b07aa155b 100644
--- a/templates/repo/actions/view.tmpl
+++ b/templates/repo/actions/view.tmpl
@@ -22,6 +22,7 @@
 		data-locale-show-timestamps="{{.locale.Tr "show_timestamps"}}"
 		data-locale-show-log-seconds="{{.locale.Tr "show_log_seconds"}}"
 		data-locale-show-full-screen="{{.locale.Tr "show_full_screen"}}"
+		data-locale-download-logs="{{.locale.Tr "download_logs"}}"
 	>
 	</div>
 </div>
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index dcbb598052..7c65d5a131 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -74,6 +74,10 @@
                 <SvgIcon name="octicon-gear" :size="18"/>
               </button>
               <div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
+                <a class="item" :href="run.link+'/jobs/'+jobIndex+'/logs'" target="_blank">
+                  <i class="icon"><SvgIcon name="octicon-download"/></i>
+                  {{ locale.downloadLogs }}
+                </a>
                 <a class="item" @click="toggleTimeDisplay('seconds')">
                   <i class="icon"><SvgIcon v-show="timeVisible['log-time-seconds']" name="octicon-check"/></i>
                   {{ locale.showLogSeconds }}
@@ -453,6 +457,7 @@ export function initRepositoryActionView() {
       showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
       showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
       showFullScreen: el.getAttribute('data-locale-show-full-screen'),
+      downloadLogs: el.getAttribute('data-locale-download-logs'),
       status: {
         unknown: el.getAttribute('data-locale-status-unknown'),
         waiting: el.getAttribute('data-locale-status-waiting'),
diff --git a/web_src/js/svg.js b/web_src/js/svg.js
index 1ea0eb8347..0a6292850a 100644
--- a/web_src/js/svg.js
+++ b/web_src/js/svg.js
@@ -22,6 +22,7 @@ import octiconDiffModified from '../../public/img/svg/octicon-diff-modified.svg'
 import octiconDiffRemoved from '../../public/img/svg/octicon-diff-removed.svg';
 import octiconDiffRenamed from '../../public/img/svg/octicon-diff-renamed.svg';
 import octiconDotFill from '../../public/img/svg/octicon-dot-fill.svg';
+import octiconDownload from '../../public/img/svg/octicon-download.svg';
 import octiconEye from '../../public/img/svg/octicon-eye.svg';
 import octiconFile from '../../public/img/svg/octicon-file.svg';
 import octiconFileDirectoryFill from '../../public/img/svg/octicon-file-directory-fill.svg';
@@ -91,6 +92,7 @@ const svgs = {
   'octicon-diff-removed': octiconDiffRemoved,
   'octicon-diff-renamed': octiconDiffRenamed,
   'octicon-dot-fill': octiconDotFill,
+  'octicon-download': octiconDownload,
   'octicon-eye': octiconEye,
   'octicon-file': octiconFile,
   'octicon-file-directory-fill': octiconFileDirectoryFill,