<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Infrastructure on StackOnward</title><link>https://stackonward.com/topics/infrastructure/</link><description>Recent content in Infrastructure on StackOnward</description><generator>Hugo</generator><language>en-US</language><atom:link href="https://stackonward.com/topics/infrastructure/index.xml" rel="self" type="application/rss+xml"/><item><title>Caddy vs Nginx vs Nginx Proxy Manager</title><link>https://stackonward.com/posts/caddy-nginx-npm-comparison/</link><pubDate>Fri, 11 Sep 2026 05:15:00 +0800</pubDate><guid>https://stackonward.com/posts/caddy-nginx-npm-comparison/</guid><description>&lt;p&gt;Caddy vs Nginx is decided by where the reverse-proxy config lives and how certificates are renewed, not by a public request-per-second ranking. A small site with files in Git and default automatic HTTPS usually belongs on Caddy. Working Nginx configs, modules, or operator muscle stay on Nginx. A web UI for a handful of hosts is Nginx Proxy Manager, and that choice also means backing up an application, a database, and generated Nginx files.&lt;/p&gt;
&lt;figure&gt;
 &lt;img src="https://stackonward.com/images/posts/caddy-nginx-npm-comparison/en/proxy-decision-tree.svg" alt="Decision tree that chooses Caddy, Nginx, or Nginx Proxy Manager from file versus web UI maintenance, then from existing Nginx assets" loading="lazy" decoding="async"&gt;
 &lt;figcaption&gt;Existing Nginx assets stay on the file path. A GUI path still needs a backup of the admin data.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="caddy-vs-nginx-is-decided-by-the-config-source-of-truth"&gt;Caddy vs Nginx is decided by the config source of truth&lt;/h2&gt;
&lt;div class="table-responsive" role="region" aria-label="Scrollable table" tabindex="0"&gt;
 &lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Axis&lt;/th&gt;
					&lt;th&gt;Caddy&lt;/th&gt;
					&lt;th&gt;Nginx&lt;/th&gt;
					&lt;th&gt;Nginx Proxy Manager&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Daily config&lt;/td&gt;
					&lt;td&gt;Caddyfile or JSON&lt;/td&gt;
					&lt;td&gt;Nginx config files&lt;/td&gt;
					&lt;td&gt;Web admin UI&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;HTTPS&lt;/td&gt;
					&lt;td&gt;Certificate issue and renewal are default&lt;/td&gt;
					&lt;td&gt;Usually Certbot, acme.sh, or a cloud certificate&lt;/td&gt;
					&lt;td&gt;Common certificate actions in the UI&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Git&lt;/td&gt;
					&lt;td&gt;Reviewed as files&lt;/td&gt;
					&lt;td&gt;Reviewed as files&lt;/td&gt;
					&lt;td&gt;Main state lives in a database&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;WebSocket&lt;/td&gt;
					&lt;td&gt;HTTP reverse proxy handles the usual upgrade&lt;/td&gt;
					&lt;td&gt;Upgrade-related headers must be set correctly&lt;/td&gt;
					&lt;td&gt;Follows the generated Nginx config&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Cache and limits&lt;/td&gt;
					&lt;td&gt;Configurable&lt;/td&gt;
					&lt;td&gt;Mature and fine-grained&lt;/td&gt;
					&lt;td&gt;Bounded by the UI and Advanced config&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Extra state&lt;/td&gt;
					&lt;td&gt;Config plus certificate storage&lt;/td&gt;
					&lt;td&gt;Config, certificates, and related scripts&lt;/td&gt;
					&lt;td&gt;App, database, certificates, and generated config&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;

&lt;/div&gt;

&lt;p&gt;Files are easy to review and roll back. A database is easy to click through, and a restore has to cover that database together with the certificates.&lt;/p&gt;</description></item><item><title>Docker Compose Production: Images, Health Checks, Secrets</title><link>https://stackonward.com/posts/docker-compose-production/</link><pubDate>Fri, 11 Sep 2026 05:12:00 +0800</pubDate><guid>https://stackonward.com/posts/docker-compose-production/</guid><description>&lt;p&gt;Docker Compose production on one host is a pinned image, durable data, a private app port, resource caps, and a stack that returns after a container or host restart. &lt;code&gt;docker compose up -d&lt;/code&gt; only reports that the containers started.&lt;/p&gt;
&lt;figure&gt;
 &lt;img src="https://stackonward.com/images/posts/docker-compose-production/en/compose-topology.svg" alt="Single-host Docker Compose production with a reverse proxy, app container, persistent volume, secret file, and size-capped logs" loading="lazy" decoding="async"&gt;
 &lt;figcaption&gt;The public internet should reach only the reverse proxy on 80/443. Keep the app port on loopback or on the container network.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="when-docker-compose-production-is-the-right-fit"&gt;When Docker Compose production is the right fit&lt;/h2&gt;
&lt;p&gt;Compose can run production when the whole stack lives on one machine, the service graph is small, and a host failure is recovered by a person or a script on another machine:&lt;/p&gt;</description></item><item><title>How to Configure Docker Volume Permissions and Secrets</title><link>https://stackonward.com/posts/docker-volume-permissions-secrets/</link><pubDate>Fri, 11 Sep 2026 05:12:00 +0800</pubDate><guid>https://stackonward.com/posts/docker-volume-permissions-secrets/</guid><description>&lt;p&gt;How to configure Docker volume permissions starts with a numeric UID and GID match, not a matching username. Put database files and other container-generated state on a named volume, bind-mount host-managed config read-only, and grant each secret only to the service that reads it as a file. Recreate the container to prove the mount. Restore a backup in a separate environment to prove the backup.&lt;/p&gt;
&lt;figure&gt;
 &lt;img src="https://stackonward.com/images/posts/docker-volume-permissions-secrets/en/storage-boundaries.svg" alt="An application container writes database state to a named volume, reads host config read-only, and reads a password from a granted secret file" loading="lazy" decoding="async"&gt;
 &lt;figcaption&gt;Named volumes hold writable state. Bind mounts hold host-managed config. Secret files are granted per service.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="choose-a-named-volume-bind-mount-or-tmpfs"&gt;Choose a named volume, bind mount, or tmpfs&lt;/h2&gt;
&lt;div class="table-responsive" role="region" aria-label="Scrollable table" tabindex="0"&gt;
 &lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Type&lt;/th&gt;
					&lt;th&gt;What belongs there&lt;/th&gt;
					&lt;th&gt;Why&lt;/th&gt;
					&lt;th&gt;Main risk&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Named volume&lt;/td&gt;
					&lt;td&gt;Databases, queues, application state&lt;/td&gt;
					&lt;td&gt;Docker manages the lifecycle; the path is not tied to one host layout&lt;/td&gt;
					&lt;td&gt;Host backup and migration must name the volume and its driver&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Bind mount&lt;/td&gt;
					&lt;td&gt;Caddyfile, explicit data directories, host scripts&lt;/td&gt;
					&lt;td&gt;The path is visible to existing backup and permission tools&lt;/td&gt;
					&lt;td&gt;The host path, owner, and SELinux or AppArmor labels all have to match&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;tmpfs&lt;/td&gt;
					&lt;td&gt;Short-lived sensitive files or fast scratch data that must not persist&lt;/td&gt;
					&lt;td&gt;Nothing is written to durable disk&lt;/td&gt;
					&lt;td&gt;A restart drops the files; the size counts against container memory&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;

&lt;/div&gt;

&lt;p&gt;&lt;a href="https://docs.docker.com/engine/storage/" target="_blank" rel="noopener"&gt;Docker storage&lt;span class="sr-only"&gt;(opens in a new tab)&lt;/span&gt;&lt;/a&gt; treats &lt;a href="https://docs.docker.com/engine/storage/volumes/" target="_blank" rel="noopener"&gt;volumes&lt;span class="sr-only"&gt;(opens in a new tab)&lt;/span&gt;&lt;/a&gt; as the default for data a container generates. &lt;a href="https://docs.docker.com/engine/storage/bind-mounts/" target="_blank" rel="noopener"&gt;Bind mounts&lt;span class="sr-only"&gt;(opens in a new tab)&lt;/span&gt;&lt;/a&gt; are for files the host already owns. &lt;a href="https://docs.docker.com/engine/storage/tmpfs/" target="_blank" rel="noopener"&gt;tmpfs&lt;span class="sr-only"&gt;(opens in a new tab)&lt;/span&gt;&lt;/a&gt; stays in host memory and is gone when the container stops. The image writable layer is the wrong place for runtime data: deleting the container deletes that layer.&lt;/p&gt;</description></item><item><title>How to Troubleshoot 502 and 504 Behind a Reverse Proxy</title><link>https://stackonward.com/posts/502-504-troubleshooting/</link><pubDate>Fri, 11 Sep 2026 05:08:00 +0800</pubDate><guid>https://stackonward.com/posts/502-504-troubleshooting/</guid><description>&lt;p&gt;How to troubleshoot 502 and 504 starts at the hop that produced the status. Both codes come from a gateway or reverse proxy: 502 Bad Gateway means that hop did not get a usable response from upstream; 504 Gateway Timeout means it waited past its limit. Keep the headers, then reach the upstream from that hop&amp;rsquo;s network.&lt;/p&gt;
&lt;figure&gt;
 &lt;img src="https://stackonward.com/images/posts/502-504-troubleshooting/en/gateway-failure-map.svg" alt="A client reaches an app through a CDN and reverse proxy; a failed connection becomes 502, a wait past the limit becomes 504" loading="lazy" decoding="async"&gt;
 &lt;figcaption&gt;Connection refused, DNS failure, and an invalid response usually point to 502. An upstream wait past the proxy deadline usually points to 504.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="how-to-troubleshoot-502-and-504-at-the-hop-that-returned-the-status"&gt;How to troubleshoot 502 and 504 at the hop that returned the status&lt;/h2&gt;
&lt;p&gt;A browser may sit behind Cloudflare or another CDN, a cloud load balancer, and Caddy or Nginx. Save the status, headers, time, and request ID:&lt;/p&gt;</description></item><item><title>Automatic rollback for a failed deployment: transaction state, traffic switch, and rollback limits</title><link>https://stackonward.com/posts/automatic-deployment-recovery/</link><pubDate>Fri, 11 Sep 2026 05:00:00 +0800</pubDate><guid>https://stackonward.com/posts/automatic-deployment-recovery/</guid><description>&lt;p&gt;Automatic rollback for a failed deployment is not &lt;code&gt;docker compose down&lt;/code&gt; after the script dies. Recovery depends on three facts: whether the candidate already took public traffic, whether the previous upstream can still be restored, and whether business checks finished. If the previous version is still serving, stop only this candidate. If the traffic switch may be half-done, restore the previous upstream. If the new version is still in the observation window, switch back. Recovery should finish the commit records only when the release has already entered the commit phase.&lt;/p&gt;</description></item><item><title>Docker Compose Blue-Green: Health Check, Switch, Drain</title><link>https://stackonward.com/posts/single-server-short-downtime-deployment/</link><pubDate>Fri, 11 Sep 2026 05:00:00 +0800</pubDate><guid>https://stackonward.com/posts/single-server-short-downtime-deployment/</guid><description>&lt;p&gt;A Docker Compose blue-green deployment on one server starts the candidate, waits until the app is ready, validates and switches the Caddy upstream, proves the public hostname, drains old connections, then stops the old version. Do not delete the old container before the candidate is taking traffic.&lt;/p&gt;
&lt;p&gt;That sequence can shrink the unavailable window. The host, disk, Docker daemon, and shared database are still single points of failure, so measure the window instead of calling the result zero downtime.&lt;/p&gt;
&lt;figure&gt;
 &lt;img src="https://stackonward.com/images/posts/single-server-short-downtime-deployment/en/blue-green-release-flow.svg" alt="Old version keeps traffic on 18080; candidate starts on 18081 and passes a loopback health check; Caddy switches, the public hostname is verified, then old connections drain" loading="lazy" decoding="async"&gt;
 &lt;figcaption&gt;The gateway switch is the only public cutover. Candidate startup and warmup happen before that switch.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="docker-compose-blue-green-only-works-under-these-constraints"&gt;Docker Compose blue-green only works under these constraints&lt;/h2&gt;
&lt;p&gt;Run two app instances on one host only when the service meets all of these conditions:&lt;/p&gt;</description></item><item><title>Why Pin a Docker Image Digest: Reproducible Deploys and Rollbacks</title><link>https://stackonward.com/posts/docker-image-digest-pinning/</link><pubDate>Fri, 11 Sep 2026 05:00:00 +0800</pubDate><guid>https://stackonward.com/posts/docker-image-digest-pinning/</guid><description>&lt;p&gt;Pin a Docker image digest by writing the production image as &lt;code&gt;repository:tag@sha256:digest&lt;/code&gt;. The version tag shows which release you planned. The digest is the content Docker must pull. &lt;code&gt;latest&lt;/code&gt;, &lt;code&gt;stable&lt;/code&gt;, or a movable version tag cannot guarantee that a rebuild or rollback returns the original image.&lt;/p&gt;
&lt;figure&gt;
 &lt;img src="https://stackonward.com/images/posts/docker-image-digest-pinning/en/image-identity-chain.svg" alt="A movable tag points to an OCI index digest, which selects amd64 or arm64 platform manifests, and the running container records the actual image" loading="lazy" decoding="async"&gt;
 &lt;figcaption&gt;Pinning the index digest freezes the platform set. Each node still pulls the manifest that matches its architecture.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="why-pin-a-docker-image-digest-instead-of-a-moving-tag"&gt;Why pin a Docker image digest instead of a moving tag&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://docs.docker.com/dhi/explore/security-concepts/digests/" target="_blank" rel="noopener"&gt;Docker&amp;rsquo;s image digest documentation&lt;span class="sr-only"&gt;(opens in a new tab)&lt;/span&gt;&lt;/a&gt; defines a digest as a SHA-256 identifier of image content. A registry maintainer can retarget the same tag to a patched image. The original digest does not move with the tag.&lt;/p&gt;</description></item><item><title>OneDev Tutorial: Docker Compose, Git, and CI/CD on One Server</title><link>https://stackonward.com/posts/onedev-production-deployment/</link><pubDate>Fri, 11 Sep 2026 04:24:00 +0800</pubDate><guid>https://stackonward.com/posts/onedev-production-deployment/</guid><description>&lt;p&gt;A OneDev tutorial starts with one instance that can host Git, review, issues, CI/CD, artifacts, and packages. Keep code on a server you operate, and skip running a separate Git service, CI, and package registry, when that trade-off is worth the ops work. The official single-node baseline is also lower than full GitLab.&lt;/p&gt;
&lt;p&gt;The environment is one 64-bit Linux host: OneDev and PostgreSQL run under Docker Compose, and Caddy terminates HTTPS. After the instance is ready, create a Go project and prove push, tests, image build, production deploy, backup, restore, and upgrade. A container in &lt;code&gt;running&lt;/code&gt; is not enough. The repository, Git SSH, the pipeline, and restore still have to work.&lt;/p&gt;</description></item><item><title>How to Expose Docker Ports: 127.0.0.1, 0.0.0.0, Firewalls</title><link>https://stackonward.com/posts/docker-port-firewall-loopback/</link><pubDate>Fri, 11 Sep 2026 04:14:00 +0800</pubDate><guid>https://stackonward.com/posts/docker-port-firewall-loopback/</guid><description>&lt;p&gt;How to expose Docker ports depends on the process listen address inside the container, the host publish bind, and the firewall or cloud security group in front of the host. Listening on &lt;code&gt;0.0.0.0&lt;/code&gt; inside a container does not put that port on the public internet.&lt;/p&gt;
&lt;figure&gt;
 &lt;img src="https://stackonward.com/images/posts/docker-port-firewall-loopback/en/network-boundaries.svg" alt="Public traffic crosses a cloud security group and host firewall to a reverse proxy; the app and database talk only on the Docker network" loading="lazy" decoding="async"&gt;
 &lt;figcaption&gt;The app and database stay on the Docker network. The public internet reaches only the ports the proxy publishes.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="how-to-expose-docker-ports-at-each-network-layer"&gt;How to expose Docker ports at each network layer&lt;/h2&gt;
&lt;div class="table-responsive" role="region" aria-label="Scrollable table" tabindex="0"&gt;
 &lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Where it is set&lt;/th&gt;
					&lt;th&gt;Example&lt;/th&gt;
					&lt;th&gt;What it means&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;App process listen&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;0.0.0.0:8080&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;Accept 8080 on every interface in this container&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;App process listen&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;127.0.0.1:8080&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;Accept 8080 only on this container’s loopback&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Docker port publish&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;127.0.0.1:8080:8080&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;Host-local processes can reach container 8080 on host 8080&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Docker port publish&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;0.0.0.0:8080:8080&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;Every IPv4 address on the host publishes 8080; the public internet may reach it&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Compose internal access&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;http://app:8080&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;A peer on the same network uses the service name and the container port&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;

&lt;/div&gt;

&lt;p&gt;The two uses of &lt;code&gt;0.0.0.0&lt;/code&gt; are not the same action. Listening on every interface inside the container lets a proxy on the same Docker network connect. Publishing the port onto every host interface widens the host entry.&lt;/p&gt;</description></item><item><title>When to Run Database Migrations: Expand, Migrate, Contract</title><link>https://stackonward.com/posts/database-migration-release-order/</link><pubDate>Fri, 11 Sep 2026 04:10:00 +0800</pubDate><guid>https://stackonward.com/posts/database-migration-release-order/</guid><description>&lt;p&gt;When to run database migrations depends on whether the current schema can serve both the old and the new application. Add compatible structure first, ship the new app, then backfill data. Drop columns, finish renames, and tighten constraints in a later release after the old version has left.&lt;/p&gt;
&lt;figure&gt;
 &lt;img src="https://stackonward.com/images/posts/database-migration-release-order/en/expand-migrate-contract.svg" alt="Expand adds compatible structure, Migrate deploys compatible apps and backfills in batches, Contract drops old structure after old consumers exit" loading="lazy" decoding="async"&gt;
 &lt;figcaption&gt;Destructive DDL is not tied to the first rollout of new code.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id="when-to-run-database-migrations-depends-on-compatibility"&gt;When to run database migrations depends on compatibility&lt;/h2&gt;
&lt;p&gt;The same DDL can wait on a lock for seconds or for minutes depending on table size, PostgreSQL major version, and live writes. Confirm those facts, plus how old and new code touch the table, before choosing a stage.&lt;/p&gt;</description></item></channel></rss>