The idea appeared after a normal working evening went slightly wrong.
I opened a repository, started a build, and noticed that nearly every useful action required a connection to somebody else’s server. The source code was hosted remotely. The CI runner was remote. Package metadata came from remote registries. Test files were downloaded from object storage. API collections were synchronized through a cloud account. Documentation lived in another browser tab. Even code completion waited for a remote model.
My laptop was powerful enough to compile the project, run several databases, and heat the room better than the radiator. Still, it behaved more like a terminal connected to a collection of external services.
So I made a simple rule for one month:
The main development workflow had to remain usable with the network disconnected.
I did not try to recreate the entire Internet in Docker. Public package registries, operating system updates, and communication tools were still allowed during planned synchronization periods. But coding, commits, builds, tests, documentation, API experiments, and basic code review had to work locally.
The first assumption was that this would mostly be a Docker Compose exercise.
It was not.
The containers were the easy part. The difficult part was discovering all the invisible contracts hidden inside a normal cloud-based workflow.
The local stack looked simple until it became a system
I started by listing the services used during an average week.
GitHub stored repositories and issues. GitHub Actions ran tests. A small S3 bucket stored test fixtures. Postman held API collections. A hosted PostgreSQL instance was occasionally used for integration testing. Documentation was scattered between Markdown files and online notes. Code completion came from a hosted AI assistant.
Replacing each item separately was easy.
The first version of the local stack looked like this:
Forgejo for Git repositories, pull requests, and issues
Woodpecker CI for pipelines
PostgreSQL for application and integration databases
MinIO for S3-compatible object storage
Mailpit for local email testing
Bruno for API collections stored directly in Git
MkDocs for documentation
Ollama for local model execution
Docker Compose for orchestration
The problem appeared when these tools had to work together.
A repository service is not just a Git server. It sends webhooks, stores SSH keys, manages users, resolves callback URLs, and expects a stable hostname. CI is not just a container executing commands. It needs credentials, volumes, networking, cache directories, artifacts, and access to the repository through an address valid from inside another container.
The phrase running locally sounds much simpler than it really is. Local to which process? Local to the host? Local to a container? Local to a virtual machine? Local to another computer on the same network?
My first pipeline failed because the CI runner tried to clone a repository from localhost. From the runner container, localhost meant the runner itself. That mistake took five minutes to understand and another hour to fix properly because replacing localhost with a random container name only created a different fragile setup.
Eventually I created a small internal development domain and mapped its names through the local DNS resolver. Services received addresses such as git.dev.home, storage.dev.home, and docs.dev.home. The browser, host processes, and containers then used the same names.
That one decision removed many strange environment-specific variables.
Here is the simplified Compose file used by the end of the first week.
Docker Compose configuration
# Language: YAML # File: compose.yaml name: local-development-platform networks: development: driver: bridge volumes: forgejo_data: forgejo_postgres_data: minio_data: ci_data: app_postgres_data: ollama_data: services: forgejo-postgres: image: postgres:16-alpine restart: unless-stopped networks: - development environment: POSTGRES_DB: forgejo POSTGRES_USER: forgejo POSTGRES_PASSWORD: ${FORGEJO_DB_PASSWORD} volumes: - forgejo_postgres_data:/var/lib/postgresql/data healthcheck: test: - CMD-SHELL - pg_isready -U forgejo -d forgejo interval: 5s timeout: 3s retries: 20 forgejo: image: codeberg.org/forgejo/forgejo:11 restart: unless-stopped depends_on: forgejo-postgres: condition: service_healthy networks: development: aliases: - git.dev.home ports: - 3000:3000 - 2222:22 environment: USER_UID: 1000 USER_GID: 1000 FORGEJO__database__DB_TYPE: postgres FORGEJO__database__HOST: forgejo-postgres:5432 FORGEJO__database__NAME: forgejo FORGEJO__database__USER: forgejo FORGEJO__database__PASSWD: ${FORGEJO_DB_PASSWORD} FORGEJO__server__DOMAIN: git.dev.home FORGEJO__server__ROOT_URL: http://git.dev.home:3000/ FORGEJO__server__SSH_DOMAIN: git.dev.home FORGEJO__server__SSH_PORT: 2222 FORGEJO__service__DISABLE_REGISTRATION: true volumes: - forgejo_data:/data - /etc/localtime:/etc/localtime:ro woodpecker-server: image: woodpeckerci/woodpecker-server:latest restart: unless-stopped depends_on: - forgejo networks: development: aliases: - ci.dev.home ports: - 8000:8000 environment: WOODPECKER_HOST: http://ci.dev.home:8000 WOODPECKER_OPEN: false WOODPECKER_FORGEJO: true WOODPECKER_FORGEJO_URL: http://git.dev.home:3000 WOODPECKER_FORGEJO_CLIENT: ${WOODPECKER_FORGEJO_CLIENT} WOODPECKER_FORGEJO_SECRET: ${WOODPECKER_FORGEJO_SECRET} WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET} volumes: - ci_data:/var/lib/woodpecker woodpecker-agent: image: woodpeckerci/woodpecker-agent:latest restart: unless-stopped depends_on: - woodpecker-server networks: - development environment: WOODPECKER_SERVER: woodpecker-server:9000 WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET} WOODPECKER_MAX_WORKFLOWS: 2 volumes: - /var/run/docker.sock:/var/run/docker.sock minio: image: minio/minio:latest restart: unless-stopped networks: development: aliases: - storage.dev.home ports: - 9000:9000 - 9001:9001 environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} command: - server - /data - --console-address - :9001 volumes: - minio_data:/data healthcheck: test: - CMD - mc - ready - local interval: 10s timeout: 5s retries: 10 application-postgres: image: postgres:16-alpine restart: unless-stopped networks: - development ports: - 5432:5432 environment: POSTGRES_DB: application POSTGRES_USER: application POSTGRES_PASSWORD: ${APPLICATION_DB_PASSWORD} volumes: - app_postgres_data:/var/lib/postgresql/data healthcheck: test: - CMD-SHELL - pg_isready -U application -d application interval: 5s timeout: 3s retries: 20 mailpit: image: axllent/mailpit:latest restart: unless-stopped networks: development: aliases: - mail.dev.home ports: - 1025:1025 - 8025:8025 ollama: image: ollama/ollama:latest restart: unless-stopped networks: development: aliases: - ai.dev.home ports: - 11434:11434 volumes: - ollama_data:/root/.ollama documentation: image: squidfunk/mkdocs-material:latest restart: unless-stopped networks: development: aliases: - docs.dev.home ports: - 8080:8000 volumes: - ./documentation:/docs command: - serve - --dev-addr - 0.0.0.0:8000
This file looks reasonably clean now. The first version definitely did not.
At one point, three services exposed port 3000 because I had copied sections without thinking. Another evening disappeared because the Git service generated webhook URLs using an address accessible from my browser but not from the CI network.
Cloud platforms hide this layer beautifully. When everything runs locally, DNS becomes part of application development whether you planned for it or not.
Reproducibility became more important than convenience
During the first few days, starting the environment required a strange ritual.
First Docker had to be running. Then the database. Then the repository server. Then I manually created a bucket in MinIO. After that, a test user had to be added to the application database. The local AI model had to be pulled separately. Finally, a few environment variables were copied from a note that should never have existed.
This was acceptable for one laptop and completely useless as a development platform.
The useful question was not whether I could run the services locally. The useful question was whether the same environment could be recreated after deleting everything.
That changed the experiment.
I stopped treating local infrastructure as a personal workstation configuration and started treating it as a disposable system. Every manual action became suspicious. If a bucket had to be created by clicking in a web interface, it belonged in a script. If an API token had to be copied from one screen to another, the process needed documentation or automation. If a service required hidden state, that state had to be backed up or generated.
The bootstrap script eventually handled dependency checks, environment creation, service startup, health checks, storage initialization, model availability, and test data.
It also refused to run with weak default secrets. That check was added after I accidentally committed a development password that had survived from the first evening. Nothing was exposed publicly, but the lesson was clear. Local does not mean harmless.
Environment bootstrap script
#!/usr/bin/env bash # Language: Bash # File: scripts/bootstrap-local-platform.sh set -Eeuo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="${ROOT_DIR}/.env" COMPOSE_FILE="${ROOT_DIR}/compose.yaml" required_commands=( docker curl openssl awk grep ) log() { printf "\n[%s] %s\n" "$(date '+%H:%M:%S')" "$1" } fail() { printf "\nERROR: %s\n" "$1" >&2 exit 1 } command_exists() { command -v "$1" >/dev/null 2>&1 } wait_for_http() { local name="$1" local url="$2" local attempts="${3:-60}" local delay="${4:-2}" log "Waiting for ${name} at ${url}" for ((i = 1; i <= attempts; i++)); do if curl \ --silent \ --show-error \ --fail \ --max-time 2 \ "${url}" >/dev/null 2>&1; then log "${name} is ready" return 0 fi printf "." sleep "${delay}" done printf "\n" fail "${name} did not become ready after ${attempts} attempts" } generate_secret() { openssl rand -base64 36 | tr -d '\n' } validate_environment_file() { local forbidden_values=( password changeme secret admin development ) for value in "${forbidden_values[@]}"; do if grep \ --ignore-case \ --extended-regexp \ "^[A-Z0-9_]+=.*${value}.*$" \ "${ENV_FILE}" >/dev/null; then fail "The environment file contains a weak value matching ${value}" fi done } create_environment_file() { if [[ -f "${ENV_FILE}" ]]; then log "Using existing environment file" validate_environment_file return fi log "Creating a new environment file" cat >"${ENV_FILE}" <<EOF FORGEJO_DB_PASSWORD=$(generate_secret) APPLICATION_DB_PASSWORD=$(generate_secret) MINIO_ROOT_USER=local-admin MINIO_ROOT_PASSWORD=$(generate_secret) WOODPECKER_AGENT_SECRET=$(generate_secret) WOODPECKER_FORGEJO_CLIENT=replace-after-oauth-registration WOODPECKER_FORGEJO_SECRET=replace-after-oauth-registration EOF chmod 600 "${ENV_FILE}" log "Environment file created at ${ENV_FILE}" log "Forgejo OAuth values still require one manual registration" } check_dependencies() { log "Checking required commands" for required_command in "${required_commands[@]}"; do if ! command_exists "${required_command}"; then fail "Missing required command: ${required_command}" fi done if ! docker info >/dev/null 2>&1; then fail "Docker is installed but the daemon is not available" fi } start_services() { log "Starting local services" docker compose \ --env-file "${ENV_FILE}" \ --file "${COMPOSE_FILE}" \ up \ --detach \ --remove-orphans wait_for_http "Forgejo" "http://localhost:3000/api/healthz" wait_for_http "MinIO" "http://localhost:9000/minio/health/live" wait_for_http "Mailpit" "http://localhost:8025/api/v1/info" wait_for_http "Documentation" "http://localhost:8080/" wait_for_http "Ollama" "http://localhost:11434/api/tags" } create_minio_bucket() { log "Creating local object-storage bucket" set -a source "${ENV_FILE}" set +a docker run \ --rm \ --network local-development-platform_development \ --entrypoint /bin/sh \ minio/mc:latest \ -c " mc alias set local \ http://minio:9000 \ '${MINIO_ROOT_USER}' \ '${MINIO_ROOT_PASSWORD}' && mc mb --ignore-existing local/development-fixtures && mc anonymous set none local/development-fixtures " } ensure_local_model() { local model="${LOCAL_MODEL:-qwen2.5-coder:7b}" log "Checking local AI model ${model}" if ! docker compose \ --env-file "${ENV_FILE}" \ --file "${COMPOSE_FILE}" \ exec \ --no-TTY \ ollama \ ollama list | awk 'NR > 1 { print $1 }' | grep --fixed-strings --line-regexp "${model}" >/dev/null; then log "Pulling model ${model}" docker compose \ --env-file "${ENV_FILE}" \ --file "${COMPOSE_FILE}" \ exec \ --no-TTY \ ollama \ ollama pull "${model}" else log "Model ${model} is already available" fi } print_summary() { cat <<EOF Local development platform is running. Forgejo: http://localhost:3000 MinIO console: http://localhost:9001 Mailpit: http://localhost:8025 Documentation: http://localhost:8080 Ollama API: http://localhost:11434 PostgreSQL: localhost:5432 The environment file is stored at: ${ENV_FILE} EOF } main() { cd "${ROOT_DIR}" check_dependencies create_environment_file start_services create_minio_bucket ensure_local_model print_summary } main "$@"
The script is longer than the commands it replaces. That initially felt wrong.
Then I deleted every container, volume, and generated file, ran it again, and returned to a working environment without opening five admin panels. At that moment, the extra lines stopped looking wasteful.
Have you ever tried rebuilding your daily environment from an empty machine? Not restoring it from an image, but actually rebuilding it from declared configuration? It is a slightly uncomfortable test.
CI without the cloud exposed assumptions hidden in the project
Moving CI locally sounded almost pointless at first. Tests already ran on the laptop, so why add another layer?
Because local tests and CI tests were not actually the same.
On the laptop, dependencies were cached. Environment variables existed. A database left from yesterday contained useful records. The browser had authentication cookies. Several test files were already downloaded. My shell profile quietly modified the PATH.
The CI runner had none of that.
The first clean pipeline found problems that had survived for months:
one test depended on execution order
a migration assumed the database already contained an extension
integration tests read credentials from a developer-specific file
the build copied an untracked configuration file
timestamps behaved differently under another timezone
two tests passed only because a cached fixture still existed
The local CI service became less about saving cloud minutes and more about creating an honest environment.
I configured each pipeline to create temporary networks and databases. Test data was generated from code. S3-compatible storage was initialized before integration tests. The pipeline ran with a fixed timezone and explicit locale. Caches were treated as optional acceleration, never as required state.
This made builds slower during the first week. A clean run that took four minutes in the hosted system took almost nine minutes locally. The main bottleneck was repeated image pulling and dependency installation.
After adding a local registry mirror, persistent package caches, and smaller test images, the median dropped below five minutes. More importantly, I could inspect every part of the process.
When a hosted runner fails, logs are usually the main window into the problem. A local runner can be entered, paused, inspected, and restarted. I could compare mounted directories, check network resolution, inspect generated files, and measure disk activity directly.
That level of visibility was addictive.
It also created a new temptation: fixing CI problems by giving the runner more access.
Mounting the Docker socket was the obvious example. It made container-based pipelines easy, but it also gave the runner enormous control over the host. For a single-user test machine, I accepted that risk temporarily. For a shared environment, I would isolate the runner in a virtual machine or use a rootless container strategy.
The experiment made one thing very clear: local infrastructure is not automatically secure infrastructure. It is simply infrastructure placed closer to you.
Local AI was useful, but not in the way I expected
The AI part attracted the most curiosity from friends who heard about the experiment.
The obvious question was whether a local model could replace a hosted coding assistant.
Not completely.
A smaller local model was slower, had a shorter useful context, and produced more confident nonsense when the task involved unfamiliar libraries. On my hardware, short completions felt acceptable. Large repository questions did not.
But the comparison became more interesting after I stopped trying to imitate the hosted tool exactly.
Instead of asking the model to understand an entire repository, I built a small retrieval script. It indexed selected source files, documentation, migration files, and architecture notes. Queries retrieved only a few relevant chunks, which were then passed to the local model.
That reduced context size and improved answers noticeably.
The local model became useful for tasks such as:
explaining an unfamiliar function
generating test cases from a small interface
converting repetitive structures
reviewing SQL migrations
finding suspicious differences between similar files
drafting documentation from code comments
summarizing failed test output
It remained weak at broad architectural decisions. That was probably healthy.
One unexpected advantage was experimentation without worrying about what left the machine. I could paste logs containing internal paths, unfinished code, database schemas, and ugly debug output. No cleanup ritual was required before every prompt.
The disadvantage was resource contention.
Running the model while compiling several containers turned the laptop into a small aircraft. Token generation slowed down, Docker became unhappy, and the browser occasionally froze for a moment. The machine had enough memory on paper, but memory bandwidth and thermal limits were more important than the number printed on the product page.
A queue solved part of the problem. AI requests were sent through a tiny local service that limited concurrency and rejected very large prompts. The service also logged prompt size, generation time, and token counts.
By the third week, the local assistant had become less magical and more predictable. That was an improvement.
Cloud AI feels like a feature. Local AI feels like a process that consumes memory, CPU or GPU time, disk space, and electricity. Seeing those costs directly changed how often I used it.
Backups became the real project
The most dangerous moment happened on day twelve.
I restarted the machine after an operating system update. Forgejo came back. MinIO came back. PostgreSQL came back. CI came back.
One repository did not.
The repository was not important. It contained a small experimental service and existed elsewhere. Still, its disappearance was enough to expose a major flaw in the plan.
I had replaced managed services with local containers but had not replaced managed durability.
Docker volumes looked persistent because they survived container recreation. That is not the same as a backup. A volume is still located on the same machine, usually on the same physical drive, managed by the same runtime, exposed to the same user mistakes.
The failure was caused by my cleanup command. I removed what I thought was an unused project and deleted its volume with it.
After that, backup work stopped being optional.
I defined three classes of local data:
reproducible data that could be regenerated from configuration
cached data that could be lost without damage
irreplaceable data that required versioned backups
Container images and package caches belonged to the second group. Generated documentation and test databases mostly belonged to the first. Git repositories, issue metadata, encryption keys, CI configuration, and object storage fixtures belonged to the third.
The backup process created consistent database dumps, exported service configuration, copied repository data, synchronized object storage, generated checksums, encrypted the archive, and moved it to another device.
A backup without a restore test is just a file collection, so the script also supported verification in an isolated temporary directory.
Backup and integrity verification utility
#!/usr/bin/env python3 # Language: Python # File: scripts/backup_local_platform.py from __future__ import annotations import argparse import hashlib import json import os import shutil import subprocess import sys import tarfile import tempfile from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path from typing import Iterable @dataclass(frozen=True) class BackupFile: relative_path: str size: int sha256: str @dataclass(frozen=True) class BackupManifest: created_at: str hostname: str files: list[BackupFile] class BackupError(RuntimeError): pass def run_command( command: list[str], *, cwd: Path | None = None, output_file: Path | None = None, ) -> None: print(f"Running: {' '.join(command)}") if output_file is None: result = subprocess.run( command, cwd=cwd, text=True, capture_output=True, check=False, ) if result.returncode != 0: raise BackupError( f"Command failed with code {result.returncode}\n" f"stdout:\n{result.stdout}\n" f"stderr:\n{result.stderr}" ) return output_file.parent.mkdir(parents=True, exist_ok=True) with output_file.open("wb") as stream: result = subprocess.run( command, cwd=cwd, stdout=stream, stderr=subprocess.PIPE, check=False, ) if result.returncode != 0: output_file.unlink(missing_ok=True) raise BackupError( f"Command failed with code {result.returncode}\n" f"stderr:\n{result.stderr.decode(errors='replace')}" ) def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str: digest = hashlib.sha256() with path.open("rb") as stream: while chunk := stream.read(chunk_size): digest.update(chunk) return digest.hexdigest() def collect_files(root: Path) -> list[BackupFile]: collected: list[BackupFile] = [] for path in sorted(root.rglob("*")): if not path.is_file(): continue relative_path = path.relative_to(root).as_posix() if relative_path == "manifest.json": continue collected.append( BackupFile( relative_path=relative_path, size=path.stat().st_size, sha256=sha256_file(path), ) ) return collected def write_manifest(root: Path) -> BackupManifest: manifest = BackupManifest( created_at=datetime.now(timezone.utc).isoformat(), hostname=os.uname().nodename, files=collect_files(root), ) manifest_path = root / "manifest.json" manifest_path.write_text( json.dumps(asdict(manifest), indent=2), encoding="utf-8", ) return manifest def verify_manifest(root: Path) -> None: manifest_path = root / "manifest.json" if not manifest_path.exists(): raise BackupError("Backup manifest is missing") manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) expected_files = manifest_data.get("files", []) failures: list[str] = [] for entry in expected_files: relative_path = entry["relative_path"] expected_size = entry["size"] expected_sha256 = entry["sha256"] path = root / relative_path if not path.exists(): failures.append(f"Missing file: {relative_path}") continue actual_size = path.stat().st_size if actual_size != expected_size: failures.append( f"Wrong size for {relative_path}: " f"expected {expected_size}, got {actual_size}" ) continue actual_sha256 = sha256_file(path) if actual_sha256 != expected_sha256: failures.append( f"Checksum mismatch for {relative_path}: " f"expected {expected_sha256}, got {actual_sha256}" ) if failures: formatted = "\n".join(f" - {failure}" for failure in failures) raise BackupError(f"Backup verification failed:\n{formatted}") print(f"Verified {len(expected_files)} files successfully") def export_postgres(destination: Path) -> None: run_command( [ "docker", "compose", "exec", "--no-TTY", "forgejo-postgres", "pg_dump", "--username=forgejo", "--format=custom", "--clean", "--if-exists", "forgejo", ], output_file=destination / "databases" / "forgejo.dump", ) run_command( [ "docker", "compose", "exec", "--no-TTY", "application-postgres", "pg_dump", "--username=application", "--format=custom", "--clean", "--if-exists", "application", ], output_file=destination / "databases" / "application.dump", ) def export_minio(destination: Path) -> None: minio_destination = destination / "object-storage" minio_destination.mkdir(parents=True, exist_ok=True) run_command( [ "docker", "run", "--rm", "--network", "local-development-platform_development", "-v", f"{minio_destination.resolve()}:/backup", "--env-file", ".env", "--entrypoint", "/bin/sh", "minio/mc:latest", "-c", ( "mc alias set local " "http://minio:9000 " "$MINIO_ROOT_USER " "$MINIO_ROOT_PASSWORD && " "mc mirror --overwrite " "local/development-fixtures " "/backup/development-fixtures" ), ] ) def export_repositories(destination: Path) -> None: repositories_destination = destination / "forgejo-data" repositories_destination.mkdir(parents=True, exist_ok=True) run_command( [ "docker", "run", "--rm", "-v", "local-development-platform_forgejo_data:/source:ro", "-v", f"{repositories_destination.resolve()}:/backup", "alpine:latest", "sh", "-c", "cd /source && tar -cf - . | tar -xf - -C /backup", ] ) def copy_configuration(project_root: Path, destination: Path) -> None: configuration_destination = destination / "configuration" configuration_destination.mkdir(parents=True, exist_ok=True) files_to_copy = [ "compose.yaml", ".env.example", "mkdocs.yml", ] directories_to_copy = [ "scripts", "documentation", ] for relative_path in files_to_copy: source = project_root / relative_path if source.exists(): shutil.copy2(source, configuration_destination / source.name) for relative_path in directories_to_copy: source = project_root / relative_path if source.exists(): shutil.copytree( source, configuration_destination / source.name, dirs_exist_ok=True, ) def create_archive(source: Path, archive_path: Path) -> None: archive_path.parent.mkdir(parents=True, exist_ok=True) with tarfile.open(archive_path, mode="w:gz") as archive: archive.add(source, arcname=source.name) print(f"Created archive: {archive_path}") def verify_archive(archive_path: Path) -> None: with tempfile.TemporaryDirectory(prefix="local-platform-restore-") as temp: restore_root = Path(temp) with tarfile.open(archive_path, mode="r:gz") as archive: archive.extractall(restore_root, filter="data") extracted_directories = [ path for path in restore_root.iterdir() if path.is_dir() ] if len(extracted_directories) != 1: raise BackupError( "Archive should contain exactly one backup directory" ) verify_manifest(extracted_directories[0]) def parse_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Back up and verify the local development platform" ) parser.add_argument( "--project-root", type=Path, default=Path.cwd(), ) parser.add_argument( "--output", type=Path, default=Path.home() / "local-platform-backups", ) parser.add_argument( "--verify", action="store_true", help="Extract the archive and verify every checksum", ) return parser.parse_args() def main() -> int: arguments = parse_arguments() project_root = arguments.project_root.resolve() output_root = arguments.output.resolve() timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") backup_name = f"local-platform-{timestamp}" with tempfile.TemporaryDirectory(prefix=f"{backup_name}-") as temp: backup_root = Path(temp) / backup_name backup_root.mkdir(parents=True) export_postgres(backup_root) export_minio(backup_root) export_repositories(backup_root) copy_configuration(project_root, backup_root) write_manifest(backup_root) verify_manifest(backup_root) archive_path = output_root / f"{backup_name}.tar.gz" create_archive(backup_root, archive_path) if arguments.verify: verify_archive(archive_path) print("Backup completed successfully") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except BackupError as error: print(f"Backup failed: {error}", file=sys.stderr) raise SystemExit(1)
The backup archive was copied to an external drive and another machine. Once a week, I restored it into a separate environment.
The first restoration failed because the backup contained database files but not the database roles expected by the application. The second failed because permissions changed during extraction. The third worked.
That was the point where I finally had something closer to a platform and not just an impressive collection of containers.
Offline development was possible, but only after defining what offline meant
The original rule required the workflow to remain useful without a network connection. During testing, I turned off Wi-Fi and unplugged the network cable.
The environment started successfully. Repositories were available. Tests ran. Documentation opened. API collections worked. The local model answered questions.
Then I tried building a new container.
The base image was missing.
After fixing that, the package manager attempted to download one small dependency that was not cached. A frontend build failed because a font was fetched during compilation. Another service tried to contact a telemetry endpoint and waited thirty seconds before timing out. The documentation theme loaded an external JavaScript file. An integration test requested a public time API that somebody had added months earlier.
The stack was local. The project was not.
I added a temporary firewall rule that blocked outgoing traffic for build containers. This was more useful than simply disconnecting the machine because it exposed network access while keeping documentation and communication available on the host.
Builds started failing in interesting places.
Several dependencies were mirrored locally. Base container images were pinned and preloaded. Test fixtures moved into the repository or object storage. External APIs received local stubs. Frontend assets were bundled instead of loaded remotely. Telemetry was explicitly disabled in development.
I did not mirror every package registry. That would have turned a one-month experiment into a new career. Instead, I defined a warm-up process before offline work:
pull required container images
download locked dependencies
synchronize selected repositories
update local documentation
refresh test fixtures
download required model files
verify checksums
run a clean build while outgoing traffic was blocked
This changed my understanding of reproducibility.
A lock file records versions, but it does not guarantee availability. A container tag names an image, but it does not guarantee that the same image will exist forever. A build script may look deterministic while quietly downloading mutable resources.
Could your current project build from scratch during a registry outage? I had assumed mine could. It could not.
What became better and what became worse
After thirty days, the result was not a heroic victory over cloud computing.
Some things became clearly better.
Local builds were more observable. Sensitive test data stayed on the machine. API collections became normal repository files. Documentation improved because it had to be stored next to the code. CI failures were easier to reproduce. Network dependencies became visible. Backups finally received serious attention.
The largest improvement was psychological.
I stopped treating development services as abstract features and started seeing them as systems with storage, queues, credentials, logs, limits, and failure modes. A managed CI pipeline no longer looked like a YAML file that magically executed commands. It looked like infrastructure somebody else was operating very carefully.
Other things became worse.
Maintenance took time. Even a small stack needed updates, cleanup, monitoring, backups, and certificate management. My laptop used more memory and battery. Fans were active more often. A failed drive would have affected several services at once. Collaboration with other people became harder because my local addresses were not automatically available to them.
The local AI model was useful but weaker than the hosted assistant for broad tasks. Local object storage worked well, but its durability depended entirely on my backup discipline. Local CI was fast after tuning, but cloud runners were easier when several jobs had to execute in parallel.
There was also a cost that does not appear in benchmark charts: attention.
When Forgejo failed to start because of a configuration change, I became the administrator. When the CI queue stopped moving, I became support. When the backup drive filled up, I became the storage team. None of these roles asked whether I had planned to write application code that evening.
Still, the month was worth it.
I returned some workloads to managed services, but not all of them. Git repositories now exist both locally and remotely. API collections stay in Git. Documentation builds locally. Integration tests use local S3-compatible storage. A local CI runner handles private experiments and pre-push validation. Hosted CI remains the final neutral environment.
The biggest change was not replacing one tool with another.
The biggest change was removing the assumption that every stage of development must depend on a live external service.
The setup I kept after the experiment
At the end of the month, I reduced the stack instead of deleting it.
Forgejo stayed as a local mirror and emergency workspace. The primary collaborative repositories still lived remotely because sharing, notifications, and external integrations were genuinely useful.
Woodpecker remained for private projects and heavy test runs. Hosted CI remained for releases and independent verification.
MinIO stayed because it made S3 integration tests faster and more realistic. Mailpit stayed because sending test emails to a real provider had never been a good idea. Bruno stayed because text-based API collections were easier to review than synchronized workspace state.
The local model stayed too, but with a narrow role. It handled small code explanations, test generation, log summaries, and private snippets. Larger questions went elsewhere after removing sensitive context.
Most importantly, the backup and restore process stayed.
Before this experiment, backups were a boring task postponed until later. After deleting a repository with one careless command, later stopped sounding like a real date.
The final setup was hybrid, not ideological.
Cloud services are excellent when they remove operational work without hiding too much control. Local services are excellent when low latency, privacy, offline access, or inspectability matter. The mistake is assuming that one side is automatically simpler.
Cloud tools are simple because somebody else accepts the complexity.
Local tools are simple only until the first disk fills up.
Final thoughts
A month without cloud development tools did not make me abandon the cloud. It made me use it more consciously.
I now know which parts of the workflow are portable, which are reproducible, and which depend on external systems in ways that are easy to miss. I also know that several supposedly local builds are local only while the package registry, container registry, font server, telemetry endpoint, and authentication provider are healthy.
Would I recommend repeating the experiment?
Yes, but not by migrating everything at once.
Take one project. Mirror the repository. Run the tests in a clean local CI runner. Replace one external storage dependency. Block outgoing network access during the build. Delete the environment and rebuild it from configuration. Restore it from backup.
The goal is not to prove that cloud services are bad.
The useful goal is to discover which parts of your work disappear when those services are unavailable.
That answer is usually more interesting than expected.