When it comes to deploying code and making it available to environments we usually go for the pattern of having a pipeline delivering it, be doing it by deploying or syncing it to a persistent storage.

So there’s a MR / PR merge into your main branch, or a new release / tag created, and then there’s a pipeline job to deliver it.

Another pattern used is the GitOps approach, where an external tool detects changes to your repository and then takes the necessary action on your deployment.

An example of this would be what Argo CD does:

  1. It polls the repo on intervals and then compares the live cluster state with the git state; if there’s a drift it can alert or apply changes depending on sync configs.
  2. The production grade approach is to use the Argo CD webhooks: an event happens, a push for example, it sends the event to the webhook, and then the same flow as above happens when there’s a drift.

And that’s similar to the pattern I want to present here by using the Kubernetes git-sync sidecar, but in this case it’s really a “simple” sync, as there would not be drift detection on this flow.

My experience with it goes back to when I started doing Airflow deployments to Kubernetes, where I had to make all DAG code available to several parts of the whole Airflow orchestration stack, like the webserver, scheduler, and workers, so that all flows and pipelines stayed up to date throughout all these components.

Initially I built my own version of a git-sync baked into airflow.

After a while I started using the earlier releases of the Airflow helm chart, already with an older version of this same git-sync from the Kubernetes project, deployed as a side-car container.

It completely suited our use case to have fast syncs, pulling from last pushed changes from a branch to a particular environment.

All CI/CD pipelines were anyway enforced by several rules, like linters, unit-tests and others, to guarantee a good delivery.

As an example I’ll be using a simple nginx container, and I’ll apply changes to the index.html file, but the 50x.html is part of the repo (it’s only a copy from the default one from nginx).

graph TD subgraph Kubernetes subgraph Pod NGINX["Nginx
container"] EMPTYDIR["EmptyDir - {}"] GITSYNC["gitsync
container"] NGINX --- |Mounts| EMPTYDIR GITSYNC --- |Mounts| EMPTYDIR end end subgraph GitHub REPO["Repository"] end GITSYNC <---> |Polls/Fetch| REPO
Github fine-grained personal access token

First step is to decide on which way git-sync will connect with your repo.

There are several alternatives: ssh keys, user/passwd and others.

I have chosen to use a fine-grained personal token. So the actions will be using my user, but the token has fine-grained permissions.

So the the fine-grained permissions will be to access the content and metadata of a single repository, and with only read-only permissions.

How to create the fine-grained personal token in github:

  • Click on your profile picture and go to Seetings.

  • On the left you will see an option called Developer Settings.

  • On Developer Settings go Personal access tokens, and you can then create a new token with only Content access Read-Only permissions.

personal token settings

  • Save the token generate, as we will be creating our secret with it’s content.

I’ll include in the references the github official guide as well.

Kubernetes resources

I’ll be creating three k8s objects for this implementation:

  • Multi-container pod - main container with nginx and a sidecar container with our git-sync.
  • configMap - this cm will contain a file key / value with the contents for the nginx server configuration (server config to change the root location of our static html files).
  • secret with the github fine-grained personal token.

Let’s generate first our secret (replace the <PERSONAL-TOKEN> with your own token value):

k create secret generic github-token-secret --from-literal="token=<PERSONAL-TOKEN>"

For the configMap I’ve generate the config file first, based on a pre-existing nginx default.conf (copied from the nginx image).

Here’s the file with the changes:

server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    #access_log  /var/log/nginx/host.access.log  main;

    location / {
        root   /usr/share/nginx/html/nginx-index.git; ### ROOT CHANGED TO THE PATH FROM GIT-SYNC
        index  index.html index.htm;
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html/nginx-index.git;
    }

    # proxy the PHP scripts to Apache listening on 127.0.0.1:80
    #
    #location ~ \.php$ {
    #    proxy_pass   http://127.0.0.1;
    #}

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    #location ~ \.php$ {
    #    root           html;
    #    fastcgi_pass   127.0.0.1:9000;
    #    fastcgi_index  index.php;
    #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
    #    include        fastcgi_params;
    #}

    # deny access to .htaccess files, if Apache's document root
    # concurs with nginx's one
    #
    #location ~ /\.ht {
    #    deny  all;
    #}
}

And here’s the imperative command to create the configMap:

k create cm nginx-default-conf --from-file=./default.conf

Ps:The key for the configMap will be the file name with the imperative command, and that’s exactly what we need to mount the configMap as the file.

This configMap will be mounted on the default location for the config at the /etc/nginx/conf.d path.

And finally here’s the multi-container pod yaml:

apiVersion: v1
kind: Pod
metadata:
  labels:
    run: nginx-git-sync
  name: nginx-git-sync
spec:
  initContainers:
  - name: git-sync
    image: k8s.gcr.io/git-sync/git-sync:v4.7.0
    restartPolicy: Always # sidecar notation with initContainers
    env:
    - name: GITSYNC_REPO
      value: "https://github.com/fernhtls/nginx-index.git"
    - name: GITSYNC_REF
      value: "main"
    - name: GITSYNC_ROOT
      value: "/git"
    - name: GITSYNC_PERIOD
      value: "30s"
    - name: GITSYNC_USERNAME
      value: "fernhtls"
    - name: GITSYNC_PASSWORD
      valueFrom:
        secretKeyRef:
          name: github-token-secret
          key: token
    volumeMounts:
    - name: repo-clone
      mountPath: /git 
  containers:
  - image: nginx
    name: nginx-git-sync
    volumeMounts:
    - name: repo-clone
      mountPath: /usr/share/nginx/html
    - name: nginx-default-conf
      mountPath: /etc/nginx/conf.d
    ports:
    - containerPort: 80
  volumes:
  - name: repo-clone
    emptyDir: {}
  - name: nginx-default-conf
    configMap:
      name: nginx-default-conf 
  dnsPolicy: ClusterFirst
  restartPolicy: Always

I’m making use of an emptyDir, as for this example I don’t need any persistency, it can be really ephemeral and stateless, and that should not be problem as the persistent side is the github repo.

Both on Airflow and here with just a static nginx the content is reloaded automatically, so no app restart / reload, so take that into consideration as well.

As explained at the start of the post, we are using a sidecar pattern, but we could switch to a init pattern if needed, and git-sync has arguments to make it only sync once and exit.

I’ve created a new file nginx-git-sync.yaml with this content, so let’s then create our pod and follow a bit the logs:

k create -f nginx-git-sync.yaml

Let’s check the logs of both containers:

k logs -f nginx-git-sync -c nginx-git-sync
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)
/docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/07/13 14:41:50 [notice] 1#1: using the "epoll" event method
2026/07/13 14:41:50 [notice] 1#1: nginx/1.31.2
2026/07/13 14:41:50 [notice] 1#1: built by gcc 14.2.0 (Debian 14.2.0-19)
2026/07/13 14:41:50 [notice] 1#1: OS: Linux 6.12.13-200.fc41.aarch64
2026/07/13 14:41:50 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2026/07/13 14:41:50 [notice] 1#1: start worker processes
2026/07/13 14:41:50 [notice] 1#1: start worker process 21
2026/07/13 14:41:50 [notice] 1#1: start worker process 22
2026/07/13 14:41:50 [notice] 1#1: start worker process 23
2026/07/13 14:41:50 [notice] 1#1: start worker process 24

We can see that our nginx container started successfully.

 k logs -f nginx-git-sync -c git-sync
INFO: detected pid 1, running init handler
{"logger":"","ts":"2026-07-13 14:41:48.736508","caller":{"file":"main.go","line":646},"level":0,"msg":"starting up","version":"v4.7.0","pid":12,"uid":65533,"gid":65533,"home":"/tmp","flags":[]}
{"logger":"","ts":"2026-07-13 14:41:48.738662","caller":{"file":"main.go","line":752},"level":0,"msg":"git version","version":"git version 2.47.3"}
{"logger":"","ts":"2026-07-13 14:41:48.749794","caller":{"file":"main.go","line":1277},"level":0,"msg":"repo directory was empty or failed checks","path":"/git"}
{"logger":"","ts":"2026-07-13 14:41:48.749822","caller":{"file":"main.go","line":1287},"level":0,"msg":"initializing repo directory","path":"/git"}
{"logger":"","ts":"2026-07-13 14:41:49.594498","caller":{"file":"main.go","line":1815},"level":0,"msg":"update required","ref":"main","local":"","remote":"9383292cefcd000fb968478d4e8d2cb98fc492aa","syncCount":0}
{"logger":"","ts":"2026-07-13 14:41:49.635707","caller":{"file":"main.go","line":1861},"level":0,"msg":"updated successfully","ref":"main","remote":"9383292cefcd000fb968478d4e8d2cb98fc492aa","syncCount":1}

And git-sync has started polling from the repository.

I could not find in the documentation if we can change the structure git-sync creates for the directory, but usually your application should be able to config a root path, just like we did with the nginx config.

The way git-sync brings in new commits is branching and creating a new worktree/HEAD for syncs, and then moving symbolic links to the new worktree/HEAD.

This was implemented to avoid that any content is half synced, and your app is already loading it, generating inconsistencies and errors.

k exec -ti nginx-git-sync -c git-sync -- bash
git-sync@nginx-git-sync:~$
git-sync@nginx-git-sync:~$ ls -ltr /git
total 0
lrwxrwxrwx. 1 git-sync git-sync 51 Jul 13 14:41 nginx-index.git -> .worktrees/9383292cefcd000fb968478d4e8d2cb98fc492aa
git-sync@nginx-git-sync:~$
git-sync@nginx-git-sync:~$ ls -ltra /git
total 0
drwxr-xr-x. 1 root     root      39 Jul 13 14:41 ..
drwxr-xr-x. 3 git-sync git-sync  54 Jul 13 14:41 .worktrees
lrwxrwxrwx. 1 git-sync git-sync  51 Jul 13 14:41 nginx-index.git -> .worktrees/9383292cefcd000fb968478d4e8d2cb98fc492aa
drwxrwxrwx. 4 root     root      59 Jul 13 14:41 .
drwxr-xr-x. 9 git-sync git-sync 181 Jul 13 14:46 .git
git-sync@nginx-git-sync:~$

We can now port-foward the main container port to access locally, and open the nginx page on a browser:

k port-forward nginx-git-sync 8081:80

personal token settings

So we only the initial commit for now, let’s push some changes to the file and commit, and check both the logs and main page again.

new changes to index.html

After doing the changes we can see the following at the git-sync logs:

k logs -f nginx-git-sync -c git-sync
INFO: detected pid 1, running init handler
{"logger":"","ts":"2026-07-13 14:41:48.736508","caller":{"file":"main.go","line":646},"level":0,"msg":"starting up","version":"v4.7.0","pid":12,"uid":65533,"gid":65533,"home":"/tmp","flags":[]}
{"logger":"","ts":"2026-07-13 14:41:48.738662","caller":{"file":"main.go","line":752},"level":0,"msg":"git version","version":"git version 2.47.3"}
{"logger":"","ts":"2026-07-13 14:41:48.749794","caller":{"file":"main.go","line":1277},"level":0,"msg":"repo directory was empty or failed checks","path":"/git"}
{"logger":"","ts":"2026-07-13 14:41:48.749822","caller":{"file":"main.go","line":1287},"level":0,"msg":"initializing repo directory","path":"/git"}
{"logger":"","ts":"2026-07-13 14:41:49.594498","caller":{"file":"main.go","line":1815},"level":0,"msg":"update required","ref":"main","local":"","remote":"9383292cefcd000fb968478d4e8d2cb98fc492aa","syncCount":0}
{"logger":"","ts":"2026-07-13 14:41:49.635707","caller":{"file":"main.go","line":1861},"level":0,"msg":"updated successfully","ref":"main","remote":"9383292cefcd000fb968478d4e8d2cb98fc492aa","syncCount":1}
{"logger":"","ts":"2026-07-13 14:58:50.167080","caller":{"file":"main.go","line":1815},"level":0,"msg":"update required","ref":"main","local":"9383292cefcd000fb968478d4e8d2cb98fc492aa","remote":"40d1db7d743fa1c841d1ffac4b1a13a258779f89","syncCount":1}
{"logger":"","ts":"2026-07-13 14:58:50.204358","caller":{"file":"main.go","line":1861},"level":0,"msg":"updated successfully","ref":"main","remote":"40d1db7d743fa1c841d1ffac4b1a13a258779f89","syncCount":2}

We can see a new update required and the git-sync bringing in the new content.

Let’s check the page again to see the new content:

personal token settings

And finally we can see the changes on git-sync wit the new symlinks and worktree:

k exec -ti nginx-git-sync -c git-sync -- bash
git-sync@nginx-git-sync:~$
git-sync@nginx-git-sync:~$ cd /git
git-sync@nginx-git-sync:/git$
git-sync@nginx-git-sync:/git$ ls -ltr
total 0
lrwxrwxrwx. 1 git-sync git-sync 51 Jul 13 14:58 nginx-index.git -> .worktrees/40d1db7d743fa1c841d1ffac4b1a13a258779f89
git-sync@nginx-git-sync:/git$
git-sync@nginx-git-sync:/git$ ls -la .worktrees/
total 0
drwxr-xr-x. 3 git-sync git-sync 54 Jul 13 14:58 .
drwxrwxrwx. 4 root     root     59 Jul 13 14:58 ..
drwxr-xr-x. 2 git-sync git-sync 69 Jul 13 14:58 40d1db7d743fa1c841d1ffac4b1a13a258779f89
git-sync@nginx-git-sync:/git$
Conclusion

Git-sync sidecar is an standard nowadays. Even though it’s not a silver bullet that serves all use cases, it’s very useful and light weight, and there’s no need for a storage middle layer.

It suits the stateless nature of Kubernetes completely - there’s no need for the content to survive app restarts, as source of truth is the repo.

On use cases where persistency is needed, you might be better suited with a GCS bucket, or a Persistent Volume within Kubernetes / GKE, so the content survives pod restarts.

Persistent Volumes might be a better choice in terms of performance, you can have a segreggated disk from the main node disk where I/O might be overloaded, plus the option for different disk types on cloud platforms.

References:

Updated: