Deploying Flask the Hard Way: Git Hooks and Unix Sockets

flask deployment git gunicorn nginx

Why Not Just Use Docker?

Containers solve most of the problems described in this post. Package your app, its dependencies, and its runtime into an image, push it to the server, run it. No permission battles, no SELinux context mismatches, no virtual environment path issues. It works.

But it works by hiding everything. I wanted to understand what sits beneath — how processes talk to each other, how Unix permissions interact with deployment, how a web request travels from the internet through nginx to an application server to Python code and back. Containers abstract all of this. I wasn't ready to abstract what I hadn't yet understood.

This is also why I didn't use a platform like Fly.io or Railway. Those services handle TLS, deployment, process management, and monitoring. You type git push and your app is live. But you learn nothing about the machinery. For a production business, that tradeoff makes sense. For a learning project, it defeats the purpose.

So I deployed Flask the way developers did before containers existed: a bare Git repository, a shell script hook, rsync, systemd, and a lot of debugging.

The Architecture

The setup has five moving parts, each running as a different user with different permissions:

A bare Git repository on the server receives pushes over SSH. A post-receive hook fires after every push, extracting the latest code to a temporary directory and copying it into the web root. Gunicorn runs the Flask application as an unprivileged service user, communicating with nginx through a Unix socket. Nginx terminates TLS, serves static files directly, and proxies dynamic requests to Gunicorn. A systemd service manages Gunicorn's lifecycle — starting, stopping, and zero-downtime reloads.

The deployment flow: I edit code on my laptop, commit, and run git push. The hook deploys the new code, installs any dependency changes, and reloads Gunicorn. The site updates with no downtime. No CI platform, no container registry, no external service.

Five Users, Five Roles

The permission model was the hardest part to get right and the most instructive.

An admin user handles server configuration with password-protected sudo. A deployer owns the web files and can restart services — nothing else. An application runner executes the Flask process with read-only access to the code. A Git user receives pushes over SSH, locked into a restricted shell that allows only Git operations. And the nginx system user serves traffic.

No user can do another's job. The Git user can't modify web files directly — it escalates through sudo to copy files as the deployer. The application runner can't modify the code it serves. The deployer can't access the application runtime. Each compromise stays contained within one role's permissions.

This separation created a puzzle: the hook runs as the Git user, but needs to write files as the deployer, install packages as the application runner, and reload a service as root. Every cross-user action required a scoped sudo rule with exact path matching. No wildcards, no shortcuts. Each rule allows one specific command and nothing more.

The Unix Socket Decision

Gunicorn can listen on a TCP port or a Unix socket. TCP means data passes through the networking stack — handshakes, packet framing, checksums, loopback interface. All that overhead for two processes on the same machine.

A Unix socket is a file on disk. Nginx writes to it, Gunicorn reads from it. Direct inter-process communication through the kernel. It's faster, and more importantly, access is controlled by file permissions instead of firewall rules. The socket file is owned by the application user with the web team group. Only processes in that group can connect. No open port, no firewall rule needed.

Everything That Broke

The deployment pipeline broke at nearly every step. Each failure taught something that documentation alone wouldn't have.

Rsync preserved source permissions instead of respecting the destination's setgid bit. Files arrived with the wrong group, and the application user couldn't read them. The fix required explicit permission correction after every sync — setting ownership, group, and mode as separate steps.

Sudo rejected commands that differed by a single flag or a single space from the rule. A --quiet flag present in the script but absent in the sudoers rule. A binary at /bin/rsync versus /usr/bin/rsync. Each mismatch produced a cryptic "command not allowed" error. Sudo's exactness is a security feature, but it means every character matters.

SELinux blocked operations that Unix permissions allowed. Files copied from /tmp carried the wrong security context. The application binary in a home directory had user_home_t context instead of bin_t. Nginx couldn't connect to the Gunicorn socket because the default policy forbids it. Each required a different fix: relabeling files, moving directories, generating custom policy modules, toggling specific booleans.

Directory traversal permissions caused silent failures. A user could be in the right group with the right file permissions, but if any parent directory in the path blocked traversal, access was denied. The error message said "Permission denied" on the file, not on the directory that was actually blocking access.

What's Missing

This pipeline has no tests. Code goes from my editor to production with nothing in between — no syntax checking, no unit tests, no integration tests. A typo in a Python file would crash Gunicorn on reload.

There's no rollback mechanism. If a bad deploy breaks the site, I'd have to manually check out a previous commit, re-run the deployment steps, and hope I remember which commit was stable.

There's no review step. The hook installs whatever dependencies requirements.txt specifies, automatically, without human approval. A compromised SSH key could push a malicious package that executes code during installation.

These are the problems that CI/CD pipelines, container registries, and deployment platforms solve. I accept them here because this is a personal blog running on a home lab, not a production service handling user data. But I understand now why those tools exist — not as unnecessary complexity, but as solutions to real risks I've seen firsthand.

What I'd Do Differently

If I built this again, I'd put the virtual environment under /opt from the start instead of a home directory. SELinux policies are more permissive there, and it's the conventional location for application-specific software on Linux.

I'd use numeric permissions in rsync flags from the beginning instead of relying on -a and then undoing its side effects with additional commands. The archive flag bundles behaviors you may not want, and peeling them back one by one is harder than building up from explicit flags.

I'd also test the full deployment chain with a dummy application before writing any real code. Half the debugging was about the pipeline, not the application. Getting the pipeline right first would have saved hours.

Why It Was Worth It

Every modern deployment tool — Docker, Kubernetes, GitHub Actions, Ansible — automates the steps I did manually. Understanding those steps means I can debug the tools when they fail, choose the right tool for a given problem, and recognize when a tool is adding complexity without adding value.

The bare repo hook approach is how developers deployed for nearly a decade before containers became mainstream. It's not obsolete knowledge — it's foundational. The server doesn't care whether a human typed the commands or a CI pipeline generated them. The same processes run, the same permissions apply, the same sockets carry traffic.

I'll probably containerize this eventually. But when I do, I'll know exactly what the container is abstracting away. That's the difference between using a tool and understanding it.