diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go
index e57447d25..89a35d850 100644
--- a/cmd/agent/agent.go
+++ b/cmd/agent/agent.go
@@ -23,6 +23,7 @@ import (
 	"net/http"
 	"os"
 	"runtime"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -49,6 +50,7 @@ import (
 )
 
 func run(c *cli.Context) error {
+	agentIDConfigPath := c.String("agent-id-config-path")
 	hostname := c.String("hostname")
 	if len(hostname) == 0 {
 		hostname, _ = os.Hostname()
@@ -109,7 +111,7 @@ func run(c *cli.Context) error {
 	}
 	defer authConn.Close()
 
-	agentID := int64(-1) // TODO: store agent id in a file
+	agentID := readAgentID(agentIDConfigPath)
 	agentToken := c.String("grpc-token")
 	authClient := agentRpc.NewAuthGrpcClient(authConn, agentToken, agentID)
 	authInterceptor, err := agentRpc.NewAuthInterceptor(authClient, 30*time.Minute)
@@ -178,6 +180,8 @@ func run(c *cli.Context) error {
 		return err
 	}
 
+	writeAgentID(agentID, agentIDConfigPath)
+
 	labels := map[string]string{
 		"hostname": hostname,
 		"platform": platform,
@@ -280,3 +284,33 @@ func stringSliceAddToMap(sl []string, m map[string]string) error {
 	}
 	return nil
 }
+
+func readAgentID(agentIDConfigPath string) int64 {
+	const defaultAgentIDValue = int64(-1)
+
+	rawAgentID, fileErr := os.ReadFile(agentIDConfigPath)
+	if fileErr != nil {
+		log.Debug().Err(fileErr).Msgf("could not open agent-id config file from %s", agentIDConfigPath)
+		return defaultAgentIDValue
+	}
+
+	strAgentID := strings.TrimSpace(string(rawAgentID))
+	agentID, parseErr := strconv.ParseInt(strAgentID, 10, 64)
+	if parseErr != nil {
+		log.Warn().Err(parseErr).Msg("could not parse agent-id config file content to int64")
+		return defaultAgentIDValue
+	}
+
+	return agentID
+}
+
+func writeAgentID(agentID int64, agentIDConfigPath string) {
+	currentAgentID := readAgentID(agentIDConfigPath)
+
+	if currentAgentID != agentID {
+		err := os.WriteFile(agentIDConfigPath, []byte(strconv.FormatInt(agentID, 10)+"\n"), 0o644)
+		if err != nil {
+			log.Warn().Err(err).Msgf("could not write agent-id config file to %s", agentIDConfigPath)
+		}
+	}
+}
diff --git a/cmd/agent/agent_test.go b/cmd/agent/agent_test.go
index 1e645e68d..ded229ed6 100644
--- a/cmd/agent/agent_test.go
+++ b/cmd/agent/agent_test.go
@@ -15,6 +15,8 @@
 package main
 
 import (
+	"fmt"
+	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -73,3 +75,91 @@ func TestStringSliceAddToMap(t *testing.T) {
 		})
 	}
 }
+
+func TestReadAgentIDFileNotExists(t *testing.T) {
+	assert.EqualValues(t, -1, readAgentID("foobar.conf"))
+}
+
+func TestReadAgentIDFileExists(t *testing.T) {
+	parameters := []struct {
+		input    string
+		expected int64
+	}{
+		{"42", 42},
+		{"42\n", 42},
+		{"  \t42\t\r\t", 42},
+		{"0", 0},
+		{"-1", -1},
+		{"foo", -1},
+		{"1f", -1},
+		{"", -1},
+		{"-42", -42},
+	}
+
+	for i := range parameters {
+		t.Run(fmt.Sprintf("Testing [%v]", i), func(t *testing.T) {
+			tmpF, errTmpF := os.CreateTemp("", "tmp_")
+			if !assert.NoError(t, errTmpF) {
+				t.FailNow()
+			}
+
+			errWrite := os.WriteFile(tmpF.Name(), []byte(parameters[i].input), 0o644)
+			if !assert.NoError(t, errWrite) {
+				t.FailNow()
+			}
+
+			actual := readAgentID(tmpF.Name())
+			assert.EqualValues(t, parameters[i].expected, actual)
+		})
+	}
+}
+
+func TestWriteAgentIDFileNotExists(t *testing.T) {
+	tmpF, errTmpF := os.CreateTemp("", "tmp_")
+	if !assert.NoError(t, errTmpF) {
+		t.FailNow()
+	}
+
+	writeAgentID(42, tmpF.Name())
+	actual, errRead := os.ReadFile(tmpF.Name())
+	if !assert.NoError(t, errRead) {
+		t.FailNow()
+	}
+	assert.EqualValues(t, "42\n", actual)
+}
+
+func TestWriteAgentIDFileExists(t *testing.T) {
+	parameters := []struct {
+		fileInput  string
+		writeInput int64
+		expected   string
+	}{
+		{"", 42, "42\n"},
+		{"\n", 42, "42\n"},
+		{"41\n", 42, "42\n"},
+		{"0", 42, "42\n"},
+		{"-1", 42, "42\n"},
+		{"foƶbar", 42, "42\n"},
+	}
+
+	for i := range parameters {
+		t.Run(fmt.Sprintf("Testing [%v]", i), func(t *testing.T) {
+			tmpF, errTmpF := os.CreateTemp("", "tmp_")
+			if !assert.NoError(t, errTmpF) {
+				t.FailNow()
+			}
+
+			errWrite := os.WriteFile(tmpF.Name(), []byte(parameters[i].fileInput), 0o644)
+			if !assert.NoError(t, errWrite) {
+				t.FailNow()
+			}
+
+			writeAgentID(parameters[i].writeInput, tmpF.Name())
+			actual, errRead := os.ReadFile(tmpF.Name())
+			if !assert.NoError(t, errRead) {
+				t.FailNow()
+			}
+			assert.EqualValues(t, parameters[i].expected, actual)
+		})
+	}
+}
diff --git a/cmd/agent/flags.go b/cmd/agent/flags.go
index 8a191b791..5b6b09c52 100644
--- a/cmd/agent/flags.go
+++ b/cmd/agent/flags.go
@@ -67,6 +67,12 @@ var flags = []cli.Flag{
 		Name:    "hostname",
 		Usage:   "agent hostname",
 	},
+	&cli.StringFlag{
+		EnvVars: []string{"WOODPECKER_AGENT_ID_FILE"},
+		Name:    "agent-id-config-path",
+		Usage:   "agent-id config file path",
+		Value:   "/etc/woodpecker/agent-id.conf",
+	},
 	&cli.StringSliceFlag{
 		EnvVars: []string{"WOODPECKER_FILTER_LABELS"},
 		Name:    "filter",
diff --git a/docker/Dockerfile.agent.alpine.multiarch b/docker/Dockerfile.agent.alpine.multiarch
index 4e88c0604..d4fba3e66 100644
--- a/docker/Dockerfile.agent.alpine.multiarch
+++ b/docker/Dockerfile.agent.alpine.multiarch
@@ -13,6 +13,7 @@ ENV GODEBUG=netdns=go
 EXPOSE 3000
 
 COPY --from=build /src/dist/woodpecker-agent /bin/
+RUN mkdir -p /etc/woodpecker
 
 HEALTHCHECK CMD ["/bin/woodpecker-agent", "ping"]
 ENTRYPOINT ["/bin/woodpecker-agent"]
diff --git a/docker/Dockerfile.agent.multiarch b/docker/Dockerfile.agent.multiarch
index 93fb8b4e6..6bcf5b766 100644
--- a/docker/Dockerfile.agent.multiarch
+++ b/docker/Dockerfile.agent.multiarch
@@ -6,6 +6,7 @@ ARG TARGETOS TARGETARCH
 RUN --mount=type=cache,target=/root/.cache/go-build \
     --mount=type=cache,target=/go/pkg \
     make build-agent
+RUN mkdir -p /etc/woodpecker
 
 FROM scratch
 ENV GODEBUG=netdns=go
@@ -15,6 +16,7 @@ EXPOSE 3000
 COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
 # copy agent binary
 COPY --from=build /src/dist/woodpecker-agent /bin/
+COPY --from=build /etc/woodpecker /etc
 
 HEALTHCHECK CMD ["/bin/woodpecker-agent", "ping"]
 ENTRYPOINT ["/bin/woodpecker-agent"]
diff --git a/docs/docs/30-administration/15-agent-config.md b/docs/docs/30-administration/15-agent-config.md
index 6d1394098..ed6526663 100644
--- a/docs/docs/30-administration/15-agent-config.md
+++ b/docs/docs/30-administration/15-agent-config.md
@@ -58,7 +58,7 @@ A shared secret used by server and agents to authenticate communication. A secre
 ### `WOODPECKER_AGENT_SECRET_FILE`
 > Default: empty
 
-Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath
+Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf`
 
 ### `WOODPECKER_LOG_LEVEL`
 > Default: empty
@@ -80,6 +80,11 @@ Disable colored debug output.
 
 Configures the agent hostname.
 
+### `WOODPECKER_AGENT_ID_FILE`
+> Default: `/etc/woodpecker/agent-id.conf`
+
+Configures the path of the agent-id.conf file.
+
 ### `WOODPECKER_MAX_WORKFLOWS`
 > Default: `1`
 
diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json
index f4dfb3611..b8f421204 100644
--- a/web/src/assets/locales/en.json
+++ b/web/src/assets/locales/en.json
@@ -342,6 +342,7 @@
                 "agents": "Agents",
                 "desc": "Agents registered for this server",
                 "none": "There are no agents yet.",
+                "id": "ID",
                 "add": "Add agent",
                 "save": "Save agent",
                 "show": "Show agents",
diff --git a/web/src/components/admin/settings/AdminAgentsTab.vue b/web/src/components/admin/settings/AdminAgentsTab.vue
index 6492bd0f0..8598173b2 100644
--- a/web/src/components/admin/settings/AdminAgentsTab.vue
+++ b/web/src/components/admin/settings/AdminAgentsTab.vue
@@ -66,6 +66,10 @@
             <TextField v-model="selectedAgent.token" :placeholder="$t('admin.settings.agents.token')" disabled />
           </InputField>
 
+          <InputField :label="$t('admin.settings.agents.id')">
+            <TextField :model-value="selectedAgent.id?.toString()" disabled />
+          </InputField>
+
           <InputField
             :label="$t('admin.settings.agents.backend.backend')"
             docs-url="docs/next/administration/backends/docker"