Knowledge / Technical Archive / ARC-001
From git push to production: how this site is built and deployed
A pipeline with no open ports and no secrets in the repository, and an honest list of what it still does not do.
ARC-001Completed2026-10-30DevSecOpsGitHub ActionsDockerCloudflare TunnelWatchtower
Publishing a post on this site is a git push. Everything after that is automatic. This page describes how it works, the security decisions behind it and, at the end, what it still does not do.
The flow
git push
-> GitHub Actions builds a Docker image
-> the image is stored in a private registry
-> Watchtower on the server pulls the new image
-> the site container is recreated
-> a Cloudflare Tunnel serves it over HTTPS
Build: a small image that runs as a normal user
- A multi-stage Dockerfile separates dependencies, build and runtime. The runtime stage only has what is needed to serve the site: the Next.js standalone output, the static files, the public assets and the content folder.
- The process runs as a non-root user.
- Development files, the project briefing and
.envfiles are kept out of the build context. - The image has a health check.
CI: GitHub Actions publishes the image
- The workflow runs on every push to
main. - It logs in to the GitHub Container Registry with the token that GitHub creates for each job. That token only lives while the job runs, and the workflow limits it to reading the code and writing packages. No long-lived credential is stored for the build.
- Each image gets two tags:
latestand the commit SHA. A bad release can be rolled back by pinning the SHA. - Layer caching makes later builds fast. The first build took about six minutes and the next one about thirty seconds.
Deploy: three containers and no inbound ports
webis the site. It is published only on the server's loopback interface, so nothing reaches it directly from the network.cloudflaredopens an outbound tunnel to Cloudflare. The router has no open ports, the home IP address is not exposed, and HTTPS ends at Cloudflare.watchtowerchecks the registry every five minutes. It only touches containers that opted in with a label, and its version is pinned. When a new image appears, it recreates the container.
Secrets
- Nothing sensitive is in git. I scanned the whole history for credential patterns and found none, and
.envfiles are ignored. - The tunnel credential lives only in a
.envfile on the server, readable by its owner, and Compose refuses to start if it is missing. - The server pulls the private image with a personal access token limited to
read:packages, with an expiry date. - The package in the registry is private.
Decisions and trade-offs
- Pull-based updates instead of a deploy webhook or SSH. The server exposes no endpoint for deployments, and the CI system holds no credentials for the server.
- A tunnel instead of port forwarding. It works behind a residential router and keeps the home IP out of DNS.
- A pinned Watchtower version. The original project turned out to be archived and incompatible with recent Docker engines, so I moved to a maintained fork. I checked the current version on Docker Hub instead of trusting a blog post, because the post was out of date.
What it still does not do
This is the honest part. The pipeline is useful, and it is also incomplete:
- Watchtower needs access to the Docker socket, which is effectively root on the host. A socket proxy that limits what it can call would reduce that risk.
- Updates are trusted by tag: whatever is pushed to
latestgets deployed. Images are not signed and nothing verifies a signature. - There is no vulnerability scan of the image or its dependencies in CI, and no software bill of materials.
- Some images use a floating tag (
cloudflareduseslatest), and none is pinned by digest. - The registry credential is stored on the server in the Docker configuration file, which only encodes it.
- Rollback is manual.
Next steps
- Add an image and dependency scan (Trivy) and secret scanning (gitleaks) to the workflow.
- Pin base images and other containers by digest.
- Sign images (cosign) and verify them before deploying.
- Put a Docker socket proxy in front of Watchtower.