Blog

  • AI Agent Consulting: A DevOps Buyer’s Guide

    AI Agent Consulting: A DevOps Guide to Evaluating, Deploying, and Securing AI Agents

    Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

    Every team building on top of large language models eventually hits the same wall: the demo works, but production doesn’t. That’s the gap AI agent consulting is supposed to fill. But “AI agent consulting” has become a catch-all term covering everything from a two-week architecture review to a six-month managed build. If you’re a developer or sysadmin evaluating whether to hire outside help or build in-house, you need to know what you’re actually buying before you sign a contract.

    This guide breaks down what AI agent consulting actually involves, how to vet a firm technically, and how to self-host a basic agent stack with Docker so you have a real baseline to compare against any proposal.

    What AI Agent Consulting Actually Involves

    Strip away the marketing language and AI agent consulting engagements usually fall into three buckets: architecture and scoping, implementation, and ongoing operations. A consultant worth paying will be explicit about which of these three you’re buying, because each has a completely different risk profile and price tag.

    Scoping and Architecture Review

    This is the cheapest and often the most valuable engagement type. A good consultant will map your existing infrastructure — your container orchestration setup, your data sources, your auth boundaries — and tell you honestly whether an agent is even the right tool for the problem. Half of the value here is someone saying “you don’t need an autonomous agent, a scheduled cron job with a single LLM call will do this more reliably and for a tenth of the cost.”

    Red flag: if the first deliverable you’re offered is a full build-out rather than a scoping document, the firm is optimizing for billable hours, not your outcome.

    Build vs. Buy: When You Need a Consultant

    You generally need outside help when at least two of the following are true: your team has never deployed an LLM-backed system to production, you have compliance requirements (HIPAA, SOC 2, PCI) that touch the agent’s data flow, or you need the system live in under 8 weeks. If none of those apply, an experienced backend team can usually build a first version faster than onboarding a consultant.

    Self-Hosting AI Agents: A Docker-First Approach

    Before paying anyone, it’s worth standing up a minimal agent yourself so you understand the moving parts a consultant would otherwise abstract away. Below is a bare-bones containerized setup using an open-source agent framework.

    Containerizing an Agent Runtime

    Here’s a minimal Dockerfile for a Python-based agent process using LangChain as the orchestration layer:

    FROM python:3.12-slim
    
    WORKDIR /app
    
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    
    COPY agent.py .
    
    ENV PYTHONUNBUFFERED=1
    
    USER 1000:1000
    
    CMD ["python", "agent.py"]

    And the accompanying docker-compose.yml that adds a vector store and a Redis instance for short-term agent memory:

    version: "3.9"
    services:
      agent:
        build: .
        restart: unless-stopped
        env_file: .env
        depends_on:
          - redis
          - vectordb
        networks:
          - agent-net
    
      redis:
        image: redis:7-alpine
        restart: unless-stopped
        volumes:
          - redis-data:/data
        networks:
          - agent-net
    
      vectordb:
        image: qdrant/qdrant:latest
        restart: unless-stopped
        volumes:
          - qdrant-data:/qdrant/storage
        networks:
          - agent-net
    
    volumes:
      redis-data:
      qdrant-data:
    
    networks:
      agent-net:
        driver: bridge

    Run it with:

    docker compose up -d --build
    docker compose logs -f agent

    This gets you a working agent loop with persistent memory in under ten minutes. It is not production-grade — there’s no rate limiting, no secrets management, and no observability — but it’s enough to have an informed conversation with any consultant who claims their stack is uniquely complex.

    Vetting an AI Agent Consulting Firm

    Once you know what a baseline build looks like, you can ask sharper questions during vendor calls. Most AI agent consulting firms will happily talk about the model they use; fewer will talk about the boring infrastructure decisions that determine whether the thing stays up.

    Technical Due Diligence Checklist

    Use this list on every call before signing a statement of work:

  • Ask which orchestration framework they use and why (LangChain, LlamaIndex, a custom loop) — vague answers are a warning sign
  • Ask how they handle prompt injection and untrusted tool inputs, referencing something like the OWASP Top 10 for LLM Applications
  • Ask who owns the deployed infrastructure after the engagement ends — you, or them
  • Ask for a sample incident: what happens when the agent calls a tool with bad arguments in production
  • Ask how costs scale with token usage and request volume, not just a flat monthly retainer
  • Ask whether they’ll hand over Infrastructure-as-Code (Terraform, Compose files, Helm charts) or leave you with a black box
  • If a firm can’t answer the incident question with a specific example, they haven’t run one of these in production yet.

    Production Infrastructure for AI Agents

    Whether you hire a consultant or build in-house, the underlying infrastructure decisions are the same. This is the part of AI agent consulting engagements that most often gets glossed over in the sales pitch.

    Hosting and Scaling Considerations

    Agent workloads are bursty and I/O-bound — most of the wall-clock time is spent waiting on model API calls, not CPU. A single mid-tier VPS from a provider like DigitalOcean or Hetzner can comfortably run dozens of concurrent agent sessions if you’re not self-hosting the model itself. Reserve GPU instances only if you’re running your own inference — for API-based agents (OpenAI, Anthropic, etc.), a $20/month droplet is usually plenty to start.

    Monitoring and Observability

    Agents fail silently more often than traditional services — a bad tool call doesn’t always throw an exception, it just produces a wrong answer. Wire up structured logging around every tool invocation and every model call, and pipe it into an uptime and log monitoring service like BetterStack so you get paged when the agent’s tool-call error rate spikes rather than discovering it from a user complaint. Our self-hosted monitoring stack guide covers a similar setup for containerized services if you’d rather run your own.

    Locking Down the Agent’s Attack Surface

    Any agent with tool access is effectively a remote code execution surface if you’re not careful. At minimum:

  • Run the agent process as a non-root container user, as shown in the Dockerfile above
  • Put the agent behind a reverse proxy with rate limiting and bot protection, such as Cloudflare
  • Never give the agent direct database credentials — proxy through a read-only API with scoped permissions
  • Sanitize and allowlist any shell or file-system tools the agent can call
  • Our Linux server hardening checklist applies directly to the VPS hosting your agent, and it’s worth running through before you go live regardless of who built the system.

    What AI Agent Consulting Costs vs. Building In-House

    Pricing in this space varies wildly, but as a rough benchmark: a scoping-only engagement typically runs $5,000–$15,000 for a 2–4 week review. A full build with a small consulting team runs $30,000–$150,000+ depending on scope, and ongoing managed operations are usually billed as a monthly retainer on top of infrastructure and model API costs. Compare that against the Docker setup above, which an experienced backend engineer can extend into a real production system in 3-6 weeks of focused work. The consulting premium buys you speed and risk transfer — it doesn’t buy you infrastructure you couldn’t have built yourself with the checklist above.


    Recommended: Want to explore DigitalOcean yourself? DigitalOcean is a direct vendor link (not an affiliate/tracked link).

    FAQ

    Is AI agent consulting worth it for a small team?
    Usually only for the scoping phase. A short paid architecture review from an experienced consultant can save months of wrong turns, but a full build-out retainer is often overkill for teams under 10 engineers who already run Docker in production.

    What’s the difference between an AI agent and a chatbot?
    A chatbot responds to messages. An agent plans multi-step actions, calls external tools or APIs, and often maintains state across a task — which is exactly why it needs more careful infrastructure and security review than a simple chat interface.

    Can I self-host an AI agent without using a paid model API?
    Yes, using an open-weights model served through something like Ollama or vLLM, but you’ll need a GPU-backed host and you should expect materially lower quality on complex reasoning tasks compared to frontier hosted models.

    How do I know if an AI agent consulting firm is legitimate?
    Ask for a reference client, ask for a sample of their Infrastructure-as-Code, and ask specifically how they handle a failed tool call in production. Vague answers to any of these are a red flag.

    Do I need Kubernetes to run an AI agent in production?
    No. A single VPS running Docker Compose, as shown above, handles the vast majority of agent workloads. Kubernetes only becomes worthwhile once you have multiple agents, multiple teams, or genuine autoscaling requirements.

    What ongoing costs should I expect after the consulting engagement ends?
    Token/API usage, hosting, monitoring, and a maintenance budget for prompt and tool updates as the underlying model changes — budget at least 10-15% of the original build cost annually for upkeep.

    AI agent consulting can genuinely save time when you’re inexperienced with production LLM systems or facing a compliance-heavy deployment. But most of the value a consultant provides is one good architecture review plus infrastructure you could stand up yourself with a weekend and the Docker Compose file above. Vet the firm on specifics, not vibes, and you’ll come out ahead either way.

  • AI Recruitment Agents: Self-Hosted Deployment Guide

    AI Recruitment Agents: Self-Hosted Deployment Guide

    Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

    AI recruitment agents are automated systems that screen resumes, schedule interviews, and communicate with candidates using language models and workflow orchestration. This guide walks through deploying AI recruitment agents on your own infrastructure, covering architecture, containerization, self-hosted orchestration, and security considerations for teams that want full control over candidate data instead of relying on a third-party SaaS platform.

    Self-hosting AI recruitment agents gives HR and engineering teams direct ownership of applicant data, predictable infrastructure costs, and the flexibility to integrate with internal applicant tracking systems (ATS). This article assumes basic familiarity with Docker and Linux server administration.

    Why Self-Host AI Recruitment Agents

    Most commercial recruitment automation tools run in a shared multi-tenant cloud, which means candidate resumes, interview transcripts, and personal data pass through a vendor’s infrastructure. For companies in regulated industries, or those simply cautious about data residency, self-hosting AI recruitment agents removes that dependency.

    A self-hosted deployment also lets you swap the underlying language model, adjust scoring logic, and connect directly to internal systems (Slack, an ATS, a calendar server) without waiting on a vendor’s integration roadmap. The tradeoff is operational responsibility: you own uptime, patching, and scaling.

    Core Components of an AI Recruitment Agent Stack

    A typical self-hosted AI recruitment agent system is composed of a handful of independent services:

  • A workflow orchestrator that sequences steps (resume parsing, scoring, scheduling, notification)
  • A language model endpoint, either self-hosted or accessed via API
  • A resume/document parser
  • A database for candidate records and pipeline state
  • A calendar/email integration layer for interview scheduling
  • A web UI or chat interface for recruiters to review agent output
  • Keeping these as separate containers rather than one monolithic script makes the system easier to debug, scale, and replace piece by piece as your needs change.

    Data Flow Through the Agent Pipeline

    When a candidate submits an application, the pipeline typically follows this sequence: ingestion (resume upload or email parsing), extraction (turning a PDF or DOCX into structured text), evaluation (the AI recruitment agent scores the candidate against job requirements), and action (scheduling an interview, sending a rejection, or flagging for human review). Each stage should be logged independently so a human recruiter can audit any decision the agent made.

    Choosing an Orchestration Layer for AI Recruitment Agents

    You don’t need to write custom orchestration code from scratch. Workflow automation tools designed for connecting APIs and running conditional logic are a natural fit for AI recruitment agents, since most of the “agent” behavior is really a sequence of API calls with decision points in between.

    If you’re already using a self-hosted automation platform, you can build the recruitment pipeline as a series of workflows: one triggered by new resume uploads, one for scoring and shortlisting, and one for scheduling. If you’re evaluating tools, our guide on how to build AI agents with n8n covers the fundamentals of chaining LLM calls with conditional branches, which applies directly to recruitment scoring logic. For a broader comparison of orchestration options, see n8n vs Make.

    Self-Hosting the Orchestrator

    Running your orchestrator on your own VPS keeps workflow logic, credentials, and candidate data on infrastructure you control. If you’re setting this up for the first time, our n8n self-hosted installation guide walks through the Docker setup, and the n8n template guide is useful once you’re ready to adapt pre-built workflows for resume scoring or interview scheduling. Budget-conscious teams comparing hosted vs. self-hosted costs may also want to review n8n cloud pricing before committing to either path.

    Containerizing the AI Recruitment Agent Stack

    Docker Compose is a practical way to define and run the multiple services an AI recruitment agent pipeline needs: the orchestrator, a vector or relational database for candidate records, and any parsing microservices.

    A minimal docker-compose.yml for a self-hosted AI recruitment agent stack might look like this:

    version: "3.8"
    services:
      orchestrator:
        image: n8nio/n8n:latest
        restart: unless-stopped
        ports:
          - "5678:5678"
        environment:
          - N8N_HOST=recruit.example.com
          - N8N_PROTOCOL=https
          - DB_TYPE=postgresdb
          - DB_POSTGRESDB_HOST=db
        depends_on:
          - db
        volumes:
          - n8n_data:/home/node/.n8n
    
      db:
        image: postgres:16
        restart: unless-stopped
        environment:
          - POSTGRES_USER=recruit
          - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
          - POSTGRES_DB=recruitment
        secrets:
          - db_password
        volumes:
          - pg_data:/var/lib/postgresql/data
    
    secrets:
      db_password:
        file: ./secrets/db_password.txt
    
    volumes:
      n8n_data:
      pg_data:

    This setup keeps the database credential out of plain environment variables using Docker secrets. For a deeper walkthrough of secrets handling in this pattern, see our Docker Compose secrets guide. If you need more detail on setting up Postgres specifically as the candidate-record database, the Postgres Docker Compose guide covers volumes, backups, and connection tuning.

    Managing Environment Configuration

    AI recruitment agents typically need several environment-specific values: API keys for the language model provider, SMTP credentials for candidate emails, and calendar integration tokens. Keep these in a .env file excluded from version control, and reference our Docker Compose env guide for patterns on variable precedence and per-environment overrides (staging vs. production).

    Rebuilding and Iterating on Agent Logic

    As you refine scoring prompts or add new pipeline stages, you’ll frequently rebuild the orchestrator or parsing service images. The Docker Compose rebuild guide covers when docker compose up --build is necessary versus when a simple restart suffices, which matters when you’re iterating quickly on agent behavior during initial rollout.

    Integrating the Language Model

    Most AI recruitment agents rely on a large language model to read resumes, compare them against a job description, and generate a fit score or summary. You have two broad options: call a hosted LLM API, or self-host an open-weight model.

    Self-hosting a model gives you full control over data handling but requires GPU resources and ongoing maintenance. Calling a hosted API is simpler operationally but means resume text leaves your infrastructure for inference. Many teams start with a hosted API behind their self-hosted orchestrator, then migrate to a self-hosted model once volume and privacy requirements justify the added complexity.

    Regardless of which approach you choose, log every prompt and response pair associated with a candidate decision. This is essential both for debugging inconsistent scoring and for being able to explain, after the fact, why an AI recruitment agent ranked one candidate above another.

    Prompt Design for Fair, Consistent Screening

    The prompt you send to the model has an outsized effect on the consistency of your AI recruitment agent’s output. A few practical guidelines:

  • Provide the job description and the candidate’s resume text as separate, clearly labeled inputs
  • Ask for structured output (JSON with specific fields) rather than free-form prose, so downstream steps can parse it reliably
  • Avoid asking the model to infer protected characteristics (age, gender, ethnicity) even indirectly
  • Include a confidence or “needs human review” flag in the output so borderline cases are routed to a recruiter rather than auto-rejected
  • Storing and Caching Candidate Data

    Candidate records, resumes, and interview notes should live in a persistent, backed-up datastore rather than ephemeral container storage. A relational database like Postgres works well for structured pipeline state (candidate status, scores, timestamps).

    If your AI recruitment agent pipeline needs a fast cache layer for deduplicating resume submissions or rate-limiting API calls to the language model, Redis is a common addition. Our Redis Docker Compose guide shows how to add a Redis service alongside your existing stack with appropriate persistence settings.

    Choosing Between a Dockerfile and Compose for Custom Services

    If you’re building a custom resume-parsing microservice rather than using an off-the-shelf image, you’ll need to decide how much of the setup belongs in a Dockerfile versus your Compose file. Our Dockerfile vs Docker Compose comparison explains the division of responsibility: the Dockerfile defines how a single service is built, while Compose defines how multiple services run together.

    Deploying to a VPS

    Once your AI recruitment agent stack is containerized, deploying it to a VPS is largely a matter of provisioning a server, installing Docker, and running docker compose up -d. Because recruitment data is sensitive, choose a provider and region that satisfies your organization’s data residency requirements, and enable disk encryption where the provider supports it.

    For teams that want full root access and predictable pricing without the overhead of a managed platform, an unmanaged VPS is a reasonable fit for this workload — see our unmanaged VPS hosting guide for the tradeoffs versus managed hosting. Providers like DigitalOcean, Hetzner, and Vultr all offer VPS tiers suitable for running an AI recruitment agent stack of this size; pick based on region availability and your existing infrastructure relationships.

    Sizing the Server

    An AI recruitment agent stack that calls an external LLM API doesn’t need GPU resources — a standard 2-4 vCPU, 4-8GB RAM VPS is typically sufficient for the orchestrator, database, and parsing services at moderate application volume. If you later self-host the language model itself, you’ll need to size separately for GPU inference, which is a materially different hosting decision.

    Monitoring, Debugging, and Logging

    Once live, an AI recruitment agent pipeline needs the same operational discipline as any other production service. Container logs are your first line of debugging when a candidate’s application gets stuck mid-pipeline or a scoring step fails silently.

    # Tail logs for the orchestrator service
    docker compose logs -f orchestrator
    
    # Check recent logs for the database container only
    docker compose logs --tail=100 db
    
    # Stop the stack cleanly during maintenance
    docker compose down

    For a deeper reference on filtering, following, and interpreting container logs across a multi-service stack like this one, see our Docker Compose logs guide, and for safely stopping and restarting the stack during upgrades, the Docker Compose down guide covers the difference between down and stop and when volumes get removed.

    Beyond container-level logs, track pipeline-level metrics: how many candidates enter each stage, average time-to-decision, and how often the agent flags a case for human review versus auto-deciding. These metrics tell you whether your AI recruitment agent is actually reducing recruiter workload or just adding a new step to babysit.

    Auditability and Explainability

    Because hiring decisions carry legal and ethical weight, every action your AI recruitment agent takes should be traceable back to the input that produced it. Store the exact resume text, job description, prompt, and model response used for each scoring decision. This lets a human reviewer reconstruct why a candidate was ranked, rejected, or advanced, and is essential if a decision is ever challenged or audited.

    Security and Compliance Considerations

    Candidate data is personal data, and in many jurisdictions that means specific legal obligations around storage, retention, and consent. A self-hosted deployment puts these obligations squarely on your team rather than a vendor, so plan for them explicitly:

  • Encrypt data at rest for the database volume and encrypt traffic in transit with TLS
  • Define a retention policy and actually delete candidate data once it expires, rather than accumulating it indefinitely
  • Restrict access to the orchestrator UI and database to authorized recruiters and admins only
  • Keep an audit log of who accessed or modified a candidate record and when
  • Review the language model provider’s data handling terms if you’re calling an external API, since resume content will pass through their infrastructure
  • Refer to official cloud security guidance such as Docker’s security documentation when hardening your container runtime, and consult the OWASP resources relevant to your application layer if you’re building custom parsing or scoring microservices rather than relying entirely on off-the-shelf components.


    Recommended: Want to explore DigitalOcean yourself? DigitalOcean is a direct vendor link (not an affiliate/tracked link).

    FAQ

    Do AI recruitment agents replace human recruiters?
    No. A well-designed AI recruitment agent handles repetitive screening and scheduling tasks, but final hiring decisions should remain with a human recruiter or hiring manager, especially for borderline or flagged candidates.

    Is self-hosting AI recruitment agents more expensive than using a SaaS platform?
    It depends on volume and existing infrastructure. Self-hosting shifts cost from a per-seat SaaS subscription to server and (optionally) LLM API usage costs, which can be cheaper at scale but requires more engineering time to maintain.

    What language model should I use for an AI recruitment agent?
    There isn’t a single correct choice — it depends on your budget, data privacy requirements, and whether you need self-hosted inference. Many teams start with a hosted API and re-evaluate once volume or privacy needs justify running an open-weight model themselves.

    How do I keep an AI recruitment agent from introducing bias into hiring?
    Design prompts that avoid inferring protected characteristics, log every decision with its inputs for auditability, route low-confidence scores to human review, and periodically review a sample of the agent’s decisions against human judgment.

    Conclusion

    Self-hosting AI recruitment agents is a practical option for teams that want direct control over candidate data, infrastructure cost, and integration flexibility. The core building blocks — a workflow orchestrator, a language model integration, structured candidate storage, and solid logging — can all run comfortably in a Docker Compose stack on a modestly sized VPS. The operational tradeoff is that your team now owns uptime, security, and compliance directly, so plan monitoring, backups, and data retention policies from day one rather than retrofitting them after your AI recruitment agent pipeline is already handling live candidates.

  • Customer Service AI Agents: Self-Hosted Deployment Guide

    Customer Service AI Agents: A DevOps Guide to Self-Hosted Deployment

    Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

    Every SaaS vendor wants to sell you a hosted customer service AI agent subscription billed per seat, per conversation, or per token. If you run infrastructure for a living, that pricing model probably makes you flinch. This guide covers what it actually takes to run customer service AI agents on your own servers — the container stack, the networking, the monitoring, and the security tradeoffs nobody puts in the marketing copy.

    This is written for developers and sysadmins who already run Docker in production and want a customer-facing AI agent that doesn’t leak conversation data to a third party by default.

    Why Self-Host Customer Service AI Agents

    Hosted customer service AI agent platforms are fast to set up, but you trade away control over three things: data residency, latency, and cost predictability. If your support volume scales past a few thousand conversations a month, the per-conversation pricing on most hosted platforms starts to hurt.

    The Case for Self-Hosting

    Self-hosting isn’t about avoiding vendors entirely — you’ll still likely call an LLM API unless you’re running a local model. The point is owning the orchestration layer:

  • Full control over conversation logs and PII retention policies
  • No per-seat or per-resolution billing surprises
  • Ability to swap the underlying LLM provider without re-platforming
  • Custom routing logic (escalate to human, trigger a refund workflow, pull order status from your own database)
  • Easier compliance with GDPR/CCPA when logs never leave infrastructure you control
  • The tradeoff is operational overhead: you own uptime, scaling, and patching. If your team already runs Docker and Kubernetes for other services, this is a manageable addition, not a new discipline.

    Architecture Overview

    A production-grade self-hosted customer service AI agent stack typically has five components:

    1. A frontend widget or chat API endpoint
    2. An orchestration service (handles conversation state, tool calls, retrieval)
    3. A vector database for knowledge-base retrieval (RAG)
    4. A connection to an LLM — either a hosted API or a self-hosted model server
    5. A logging/observability layer for auditing and QA

    Each of these runs as its own container, which makes the whole thing reproducible and easy to move between cloud providers.

    Building the Stack with Docker

    Here’s a minimal docker-compose.yml that stands up an orchestration service, a Postgres-backed vector store using pgvector, and Redis for session state:

    version: "3.9"
    services:
      agent-orchestrator:
        build: ./orchestrator
        ports:
          - "8080:8080"
        environment:
          - LLM_API_KEY=${LLM_API_KEY}
          - VECTOR_DB_URL=postgresql://agent:agent@vector-db:5432/agentdb
          - REDIS_URL=redis://session-store:6379
        depends_on:
          - vector-db
          - session-store
        restart: unless-stopped
    
      vector-db:
        image: ankane/pgvector:latest
        environment:
          - POSTGRES_USER=agent
          - POSTGRES_PASSWORD=agent
          - POSTGRES_DB=agentdb
        volumes:
          - vector_data:/var/lib/postgresql/data
    
      session-store:
        image: redis:7-alpine
        volumes:
          - redis_data:/data
    
    volumes:
      vector_data:
      redis_data:

    The orchestrator is your own service — usually a lightweight Python app using something like LangChain or a hand-rolled loop that calls the LLM API, checks the vector store for relevant docs, and decides whether to answer directly or hand off to a human. A minimal Python handler looks like this:

    from fastapi import FastAPI, Request
    import httpx
    
    app = FastAPI()
    
    @app.post("/chat")
    async def chat(request: Request):
        body = await request.json()
        message = body["message"]
        session_id = body["session_id"]
    
        context = await retrieve_context(message)
        response = await call_llm(message, context)
    
        if response.confidence < 0.6:
            return {"action": "escalate_to_human", "session_id": session_id}
    
        return {"action": "reply", "text": response.text}

    Bring this up locally with:

    docker compose up -d
    docker compose logs -f agent-orchestrator

    If you’re new to multi-container setups, our Docker Compose guide for beginners covers the fundamentals of service dependencies and volumes used above.

    Deploying to Production

    Choosing Infrastructure

    Customer service AI agents are latency-sensitive — a slow response feels broken to a user mid-conversation. Pick infrastructure based on where your customers actually are, not where it’s cheapest to spin up a box.

    For most small-to-mid teams, a couple of solid options:

  • DigitalOcean droplets with managed Kubernetes if you want a simple, well-documented path to container orchestration
  • Hetzner dedicated or cloud servers if you need raw compute per dollar, especially for running your own vector database or a self-hosted model
  • Both work well behind a reverse proxy like Traefik or Nginx, and both support the Docker Compose stack above with minimal changes. If you’re running the orchestrator plus a vector database plus Redis, a 4 vCPU / 8GB instance is a reasonable starting point — scale the vector database vertically first since it’s usually the bottleneck under concurrent load.

    Networking and TLS

    Don’t expose the chat API without TLS. A basic Nginx reverse proxy config in front of the orchestrator:

    server {
        listen 443 ssl;
        server_name support.yourdomain.com;
    
        ssl_certificate /etc/letsencrypt/live/support.yourdomain.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/support.yourdomain.com/privkey.pem;
    
        location / {
            proxy_pass http://127.0.0.1:8080;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header Host $host;
        }
    }

    Pair this with Cloudflare in front of your origin for DDoS protection and edge caching of static widget assets — customer service traffic tends to spike unpredictably (a bad product release, an outage, a viral complaint), and edge protection absorbs that better than scaling your origin reactively.

    Monitoring and Logging

    A customer service AI agent that silently fails is worse than one that’s slow — customers get wrong answers with no visible error. You need application-level monitoring, not just container uptime checks.

    At minimum, track:

  • Response latency per conversation turn
  • Escalation rate (how often the agent hands off to a human)
  • LLM API error rate and rate-limit hits
  • Vector store query latency
  • BetterStack works well here since it combines uptime monitoring with log aggregation, so you can alert on both “the container is down” and “escalation rate just spiked 300% in the last hour,” which is often the earlier warning sign of a real problem. Ship logs from the orchestrator container using a simple driver:

    docker run -d 
      --log-driver=syslog 
      --log-opt syslog-address=udp://logs.betterstack.com:514 
      --name agent-orchestrator 
      your-org/agent-orchestrator:latest

    Alerting on Escalation Spikes

    Set an alert threshold on escalation rate specifically. A sudden jump usually means either the knowledge base is stale (a product changed and the agent doesn’t know) or the LLM provider is degraded. Catching this in minutes instead of finding out from angry tickets is the whole point of running your own observability stack.

    Security Considerations

    Customer service conversations often contain PII — order numbers, emails, sometimes payment details users paste in by mistake. Treat the conversation logs like any other sensitive customer data store.

  • Encrypt data at rest for both the vector database and Redis session store
  • Redact or hash PII before it’s sent to a third-party LLM API if your compliance posture requires it
  • Rotate API keys for the LLM provider and store them in a secrets manager, not in docker-compose.yml directly
  • Set conversation log retention limits and actually enforce deletion — don’t just document the policy
  • If you’re self-hosting the LLM itself instead of calling an external API, review our Linux server hardening checklist before exposing any inference endpoint publicly — model servers are a newer attack surface and often shipped with permissive defaults.

    Recommended: Ready to put this into practice? DigitalOcean is a tool we use for exactly this, and we have a real, disclosed affiliate relationship with them.

    FAQ

    Do I need a GPU to self-host customer service AI agents?
    Not if you’re calling a hosted LLM API (OpenAI, Anthropic, etc.) for the actual language model and only self-hosting the orchestration layer. You only need GPU infrastructure if you’re also self-hosting the underlying model itself, which adds significant operational complexity.

    How is this different from just using a chatbot builder?
    Most no-code chatbot builders don’t give you control over the retrieval pipeline, escalation logic, or where conversation data is stored. Self-hosting the orchestration layer means you own that logic and can integrate directly with your own database, ticketing system, or CRM.

    What’s a realistic team size to maintain this?
    One DevOps engineer can maintain this stack alongside other responsibilities once it’s deployed, assuming you already run Docker in production. Initial setup and integration with your knowledge base is the bulk of the effort.

    Can this replace human support entirely?
    No, and it shouldn’t try to. The escalation path to a human agent is a core part of the architecture, not an afterthought. Confidence-based handoff keeps the agent from confidently giving wrong answers.

    How do I keep the knowledge base current?
    Automate re-indexing of your vector database whenever your docs, help center, or product changelog updates. A stale knowledge base is the most common cause of bad AI agent responses in production.

    Is it cheaper than a hosted platform long-term?
    Usually yes past a certain volume threshold, since you’re paying infrastructure costs plus LLM API usage instead of a per-resolution markup. Below a few thousand conversations a month, a hosted platform may still be cheaper once you account for engineering time.

    Self-hosting customer service AI agents isn’t the right call for every team, but if you already run containerized infrastructure, it gives you control over cost, data, and behavior that hosted platforms can’t match. Start small — one orchestrator container, one knowledge base, a clear escalation path — and scale the stack as conversation volume grows.

  • Automated SEO Services: Build Your Own DevOps Stack

    Automated SEO Services: How to Build a Self-Hosted Stack Instead of Renting One

    Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

    Most “automated SEO services” on the market are just cron jobs and API calls wrapped in a dashboard and a monthly invoice. If you’re a developer or sysadmin who already runs infrastructure, there’s a strong case for building your own automation layer instead of paying $200+/month for a black-box tool that you can’t inspect, extend, or self-host.

    This article walks through a practical, DevOps-style approach to automated SEO services: crawling, monitoring, alerting, and reporting, all running on infrastructure you control. If you already manage a Docker monitoring stack or a self-hosted analytics setup, this fits right alongside it.

    Why “Automated SEO Services” Usually Means a SaaS Subscription

    When people search for automated SEO services, they’re typically pointed toward platforms like Ahrefs, SEMrush, or SE Ranking. These tools are genuinely useful — they maintain massive crawl databases and keyword indexes that are expensive to replicate. But a large chunk of what they sell as “automation” is functionality you can build yourself in an afternoon:

  • Scheduled site crawls that flag broken links, missing meta tags, and duplicate titles
  • Automated Core Web Vitals checks via Lighthouse or PageSpeed Insights API
  • Rank tracking against a fixed keyword list
  • Sitemap and robots.txt validation
  • Uptime and SSL expiry monitoring for SEO-critical pages
  • Slack/email alerts when something breaks
  • If you already have a VPS, Docker, and basic scripting skills, you can automate most of this without a subscription — and layer in a paid rank-tracking API only where it’s genuinely hard to replicate.

    Architecture: What a Self-Hosted SEO Automation Stack Looks Like

    A minimal stack looks like this:

  • Crawler containerScreaming Frog CLI (headless mode) or a custom Python crawler using requests + BeautifulSoup
  • Scheduler — cron inside a container, or a lightweight job runner like ofelia
  • Storage — Postgres or SQLite for crawl history and diffing
  • Alerting — a webhook to Slack or a monitoring tool like BetterStack
  • Dashboard — Grafana reading from Postgres, or a static HTML report generated on each run
  • Here’s a docker-compose.yml that stitches the core pieces together:

    version: "3.8"
    services:
      crawler:
        build: ./crawler
        volumes:
          - ./data:/data
        environment:
          - TARGET_URL=https://thinkstreamtv.com
          - SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL}
    
      scheduler:
        image: mcuadros/ofelia:latest
        depends_on:
          - crawler
        volumes:
          - ./ofelia.ini:/etc/ofelia/config.ini
        command: daemon --config=/etc/ofelia/config.ini
    
      postgres:
        image: postgres:16
        environment:
          - POSTGRES_PASSWORD=seo_automation
          - POSTGRES_DB=seo_reports
        volumes:
          - pg_data:/var/lib/postgresql/data
    
    volumes:
      pg_data:

    And a matching ofelia.ini to run the crawl nightly:

    [job-run "nightly-crawl"]
    schedule = @daily
    container = crawler
    command = python crawl.py

    Automating Technical SEO Checks with a Simple Script

    You don’t need enterprise tooling to catch the most common technical SEO regressions. This Python script checks status codes, title tags, and meta descriptions across a sitemap, then posts a Slack alert if anything looks wrong:

    import requests
    import xml.etree.ElementTree as ET
    import os
    
    SITEMAP_URL = "https://thinkstreamtv.com/sitemap.xml"
    SLACK_WEBHOOK = os.environ["SLACK_WEBHOOK_URL"]
    
    def get_urls():
        resp = requests.get(SITEMAP_URL, timeout=10)
        root = ET.fromstring(resp.content)
        ns = {"ns": "http://www.sitemaps.org/schemas/sitemap/0.9"}
        return [loc.text for loc in root.findall(".//ns:loc", ns)]
    
    def check_page(url):
        issues = []
        resp = requests.get(url, timeout=10)
        if resp.status_code != 200:
            issues.append(f"Bad status {resp.status_code}")
        html = resp.text
        if "<title>" not in html:
            issues.append("Missing <title> tag")
        if 'name="description"' not in html:
            issues.append("Missing meta description")
        return issues
    
    def notify(url, issues):
        text = f":warning: SEO issue on {url}: {', '.join(issues)}"
        requests.post(SLACK_WEBHOOK, json={"text": text})
    
    if __name__ == "__main__":
        for url in get_urls():
            problems = check_page(url)
            if problems:
                notify(url, problems)

    Run it on a schedule with cron:

    # /etc/cron.d/seo-check
    0 3 * * * root docker exec seo-crawler python /app/crawl.py >> /var/log/seo-check.log 2>&1

    This one script alone replaces a meaningful chunk of what paid “automated SEO services” charge for: broken page detection, missing metadata alerts, and daily monitoring — with zero recurring cost beyond the VPS itself.

    Rank Tracking: Where Self-Hosting Hits Its Limit

    Crawling your own site is easy to self-host. Tracking keyword rankings across Google’s index is not — it requires proxy rotation, CAPTCHA handling, and constant maintenance against Google’s anti-scraping measures. This is the one area where a dedicated automated SEO service earns its subscription fee.

    If you need reliable rank tracking, pair your self-hosted crawler with a rank-tracking API rather than scraping Google directly yourself. SE Ranking offers an API you can call from the same automation pipeline described above, which keeps your dashboard unified while offloading the hard part (actual SERP scraping) to a service built for it.

    curl -X GET "https://api.seranking.com/v1/keywords/positions" 
      -H "Authorization: Bearer ${SE_RANKING_API_KEY}" 
      -H "Content-Type: application/json"

    Feed the response into your Postgres instance alongside the crawl data, and you have a single dashboard combining technical SEO health and ranking trends — without paying for a full SaaS suite you’ll only use 20% of.

    Hosting Considerations for Your SEO Automation Stack

    This kind of stack doesn’t need much horsepower — a $6-12/month VPS handles daily crawls of most mid-sized sites comfortably. What matters more is reliability and predictable I/O, since crawls are bursty. A DigitalOcean droplet or a Hetzner Cloud instance both work well here; Hetzner tends to win on raw price-per-core if your crawler is CPU-bound, while DigitalOcean’s managed Postgres add-on simplifies the database layer if you’d rather not run it yourself.

    Whichever provider you pick, put uptime monitoring in front of the automation itself — if the crawler container dies silently, you lose the SEO visibility it was built to provide. BetterStack can monitor the scheduler’s heartbeat endpoint and page you if a scheduled run doesn’t complete.

    Turning Crawl Data Into Reports

    Once data lands in Postgres, a simple Grafana panel gives you trend lines without building a custom frontend:

  • Pages returning non-200 status codes over time
  • Average title tag length distribution
  • Count of pages missing meta descriptions
  • Historical keyword position changes (if using a rank-tracking API)
  • For teams that want a shareable weekly digest instead of a live dashboard, generate a static HTML summary at the end of each crawl and email it via msmtp or a transactional email API. This mirrors the “weekly SEO report” email that most paid tools send, minus the subscription.

    Where This Approach Makes Sense (and Where It Doesn’t)

    Self-hosted automation makes the most sense when:

  • You already run Docker infrastructure and are comfortable maintaining another small service
  • Your site is large enough that manual checks are impractical, but not so large that crawling requires distributed infrastructure
  • You want full control over data retention and don’t want crawl history locked inside a vendor’s dashboard
  • It makes less sense if you need competitive keyword research, backlink analysis, or content gap analysis — those require large, continuously updated third-party indexes that aren’t practical to replicate. In that case, treat a paid tool as a research instrument and keep the monitoring/alerting layer in-house.


    Recommended: Ready to put this into practice? SE Ranking is a tool we use for exactly this, and we have a real, disclosed affiliate relationship with them.

    FAQ

    What are automated SEO services, exactly?
    They’re tools or platforms that run recurring SEO tasks — crawling, rank tracking, technical audits, reporting — on a schedule without manual intervention. This can be a paid SaaS platform or a self-hosted pipeline built from cron jobs, scripts, and APIs.

    Can I fully replace a paid SEO tool with self-hosted automation?
    For technical SEO monitoring (broken links, missing metadata, uptime, Core Web Vitals) — yes, largely. For competitive keyword research and rank tracking across Google’s index, self-hosting is impractical; use a rank-tracking API alongside your own crawler instead.

    How often should automated SEO checks run?
    Daily is standard for technical crawls on small-to-mid-sized sites. Rank tracking is typically checked weekly, since daily fluctuations in Google’s SERPs are noisy and rarely actionable.

    Do I need Kubernetes to run this kind of stack?
    No. A single VPS with Docker Compose, as shown above, is sufficient for most sites. Kubernetes only becomes worth the overhead if you’re crawling many large sites in parallel.

    What’s the cheapest way to get alerts when something breaks?
    A Slack incoming webhook is free and takes minutes to set up. Pair it with an uptime monitor like BetterStack for infrastructure-level failures (e.g., the crawler container itself going down).

    Will Google penalize me for running frequent automated crawls of my own site?
    No — crawling your own site with a reasonable rate limit doesn’t affect rankings. Just make sure your crawler respects your own robots.txt and doesn’t hammer the server hard enough to trigger rate limiting or affect real user traffic.

    Final Thoughts

    Automated SEO services don’t have to mean handing recurring revenue to a SaaS vendor for functionality you could build in a weekend. Start with a self-hosted crawler, cron-based scheduling, and Slack alerts — then layer in a paid rank-tracking API only for the piece that genuinely requires third-party infrastructure. If you’re already comfortable with Docker and basic scripting, this is one of the more straightforward pieces of DevOps tooling you can own outright instead of renting.

  • n8n Automation: Self-Host a Workflow Engine on a VPS

    n8n Automation: How to Self-Host a Workflow Engine on Your Own VPS

    Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

    If you’ve spent any time evaluating workflow automation tools, you’ve probably run into n8n. It’s a fair-code, node-based automation platform that lets you connect APIs, databases, and internal services without writing a full application for every integration. Unlike Zapier or Make, n8n automation runs entirely on infrastructure you control, which matters if you care about data residency, API rate limits, or just not paying per-execution fees forever.

    This guide walks through installing n8n automation on a Linux VPS with Docker, securing it behind a reverse proxy, wiring up your first workflow, and keeping the instance monitored and backed up. It’s written for developers and sysadmins who are comfortable with a terminal and want a production-ready setup, not a five-minute demo that falls over the first time a webhook gets hit twice.

    If you don’t already have a server, DigitalOcean droplets are a solid, cheap starting point for n8n automation workloads — a $6/month droplet handles light-to-moderate workflow volume without issue. For workflows that need to survive real traffic and cron-triggered jobs around the clock, Hetzner also offers excellent price-to-performance ratios on their cloud instances.

    What n8n Automation Actually Solves

    Most teams reach for n8n automation when they have three or more systems that need to talk to each other and none of them share a native integration. Think: a form submission in Typeform that needs to create a CRM record, notify a Slack channel, and append a row to a spreadsheet. You could write a small serverless function for each hop, or you could build one n8n workflow with three nodes and a trigger.

    The advantages over hosted-only tools like Zapier are concrete:

  • No per-execution billing — once it’s running, workflows cost you server resources, not a monthly execution cap.
  • Full data control — sensitive payloads never leave your infrastructure.
  • Custom code nodes — you can drop in raw JavaScript or Python when a built-in node doesn’t cover your case.
  • Self-hosted webhooks — no third-party proxy sitting between your systems.
  • Version control friendly — workflows can be exported as JSON and tracked in git.
  • If you’re already running other containerized services, pairing n8n automation with your existing Docker Compose stack is usually the path of least resistance.

    Installing n8n Automation with Docker Compose

    The official n8n documentation recommends Docker for anything beyond a local test, and that’s the approach we’ll use here. First, create a working directory and a docker-compose.yml:

    mkdir -p ~/n8n-stack/data
    cd ~/n8n-stack

    # docker-compose.yml
    version: "3.8"
    
    services:
      n8n:
        image: n8nio/n8n:latest
        restart: unless-stopped
        ports:
          - "127.0.0.1:5678:5678"
        environment:
          - N8N_HOST=n8n.yourdomain.com
          - N8N_PROTOCOL=https
          - N8N_PORT=5678
          - WEBHOOK_URL=https://n8n.yourdomain.com/
          - GENERIC_TIMEZONE=America/New_York
          - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
          - DB_TYPE=postgresdb
          - DB_POSTGRESDB_HOST=postgres
          - DB_POSTGRESDB_DATABASE=n8n
          - DB_POSTGRESDB_USER=n8n
          - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
        volumes:
          - ./data:/home/node/.n8n
        depends_on:
          - postgres
    
      postgres:
        image: postgres:16-alpine
        restart: unless-stopped
        environment:
          - POSTGRES_DB=n8n
          - POSTGRES_USER=n8n
          - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
        volumes:
          - ./pgdata:/var/lib/postgresql/data

    Create a .env file next to it with strong random values:

    echo "N8N_ENCRYPTION_KEY=$(openssl rand -hex 24)" >> .env
    echo "POSTGRES_PASSWORD=$(openssl rand -hex 16)" >> .env

    Bind it to 127.0.0.1 on the host as shown above — you don’t want n8n exposed directly on the public interface before it’s behind TLS. Bring the stack up:

    docker compose up -d
    docker compose logs -f n8n

    Using Postgres instead of the default SQLite file matters once you have more than a handful of workflows — SQLite will work for testing, but concurrent webhook executions against a file-based database are a common source of “database is locked” errors in n8n automation setups that grow past a toy project.

    Building Your First n8n Automation Workflow

    Once the container is up, n8n automation is reachable on port 5678 (we’ll put it behind a domain in the next section). Log in, and you’ll land on a blank canvas. A minimal but genuinely useful first workflow looks like this:

    1. Webhook node — set as the trigger, generates a unique URL n8n listens on.
    2. HTTP Request node — call an external API (weather, exchange rates, your own backend) using the payload from the webhook.
    3. IF node — branch logic based on the response, e.g., route differently if a status field equals "error".
    4. Slack or Email node — send a formatted notification on either branch.

    Each node’s output is inspectable in the UI, which makes debugging far faster than tailing logs from a hand-rolled script. You can also drop in a Code node for anything the built-in nodes can’t express:

    // Code node example: normalize incoming payload keys to snake_case
    const items = $input.all();
    
    return items.map(item => {
      const normalized = {};
      for (const [key, value] of Object.entries(item.json)) {
        const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
        normalized[snakeKey] = value;
      }
      return { json: normalized };
    });

    Save the workflow, toggle it Active, and the webhook URL becomes permanently listening — no manual re-trigger needed. This is where n8n automation earns its keep: workflows that used to be a cron job plus a Python script plus a systemd unit become one visual, exportable definition.

    Securing n8n Automation Behind Nginx and SSL

    Running n8n on a bare port with no TLS is a non-starter for anything beyond localhost testing, since workflows frequently handle API keys and webhook secrets. Install Nginx and Certbot on the host:

    sudo apt update
    sudo apt install -y nginx certbot python3-certbot-nginx

    Create a server block:

    # /etc/nginx/sites-available/n8n.conf
    server {
        listen 80;
        server_name n8n.yourdomain.com;
    
        location / {
            proxy_pass http://127.0.0.1:5678;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }

    sudo ln -s /etc/nginx/sites-available/n8n.conf /etc/nginx/sites-enabled/
    sudo nginx -t && sudo systemctl reload nginx
    sudo certbot --nginx -d n8n.yourdomain.com

    Certbot and Let’s Encrypt handle certificate issuance and renewal automatically once set up — verify the renewal timer is active with systemctl list-timers | grep certbot. If you followed our Nginx reverse proxy and SSL guide for other services on the same box, this configuration will look familiar; the pattern is identical.

    Don’t stop at TLS. n8n automation instances are frequently targeted by credential-stuffing bots once discovered, since a compromised instance can pivot into every connected service. At minimum:

  • Set N8N_BASIC_AUTH_ACTIVE=true with a strong username/password pair as an extra layer in front of the app-level login.
  • Restrict SSH and the Postgres port with ufw so nothing but Nginx faces the internet.
  • Rotate the N8N_ENCRYPTION_KEY only during a planned migration — rotating it without re-encrypting stored credentials will break every saved connection.
  • Monitoring and Backing Up n8n Automation

    A workflow engine that silently stops running webhooks is worse than one that never existed, because nobody notices until a downstream process fails days later. Add an uptime check against a lightweight health endpoint:

    curl -s -o /dev/null -w "%{http_code}" https://n8n.yourdomain.com/healthz

    Wire that check into BetterStack (or any uptime monitor) so you get paged the moment the container dies or Nginx starts returning 502s. For workflows that run on a schedule rather than a webhook, also monitor execution history inside n8n itself — the built-in Executions tab shows failures, and you can add an Error Trigger node to route failures to a dedicated Slack channel automatically.

    Backups are non-negotiable once workflows are doing real work. Two things need to be saved: the Postgres database (workflow definitions, credentials, execution history) and the .n8n data directory (encryption key, local files). A simple nightly cron job:

    #!/usr/bin/env bash
    # /usr/local/bin/n8n-backup.sh
    set -euo pipefail
    
    BACKUP_DIR=/root/backups/n8n
    TIMESTAMP=$(date +%Y%m%d_%H%M%S)
    mkdir -p "$BACKUP_DIR"
    
    docker exec n8n-stack-postgres-1 pg_dump -U n8n n8n | gzip > "$BACKUP_DIR/n8n_db_${TIMESTAMP}.sql.gz"
    tar czf "$BACKUP_DIR/n8n_data_${TIMESTAMP}.tar.gz" -C ~/n8n-stack data
    
    find "$BACKUP_DIR" -type f -mtime +14 -delete

    chmod +x /usr/local/bin/n8n-backup.sh
    (crontab -l 2>/dev/null; echo "0 3 * * * /usr/local/bin/n8n-backup.sh") | crontab -

    Ship the resulting archives off-box — an S3-compatible bucket or a second small VPS is enough. A backup that lives on the same disk as the service it protects isn’t really a backup.

    Scaling n8n Automation for Production

    A single-container setup handles most small-to-medium workloads fine, but if you’re running hundreds of active workflows or high-frequency webhooks, n8n supports a queue mode that separates the editor UI from execution workers using Redis. In queue mode, incoming webhook triggers get pushed onto a Redis queue and picked up by one or more worker containers, which means you can scale execution capacity horizontally without touching the main instance. This is also the point where it’s worth revisiting your VPS sizing — workers are CPU and memory hungry under load, and undersizing here is the most common cause of stalled queues.

    If you’re integrating n8n automation with dozens of external APIs, also keep an eye on outbound rate limits. n8n doesn’t rate-limit HTTP Request nodes by default, so a bug in a loop or a misconfigured retry can burn through an API quota in minutes. Add explicit Wait nodes or batch processing for any integration with a published rate limit.


    Recommended: Want to explore DigitalOcean yourself? DigitalOcean is a direct vendor link (not an affiliate/tracked link).

    FAQ

    Is n8n automation free to self-host?
    Yes. n8n is released under a fair-code license (Sustainable Use License), which means you can self-host and use it commercially for free. You only pay if you use n8n’s own cloud hosting or need enterprise features like SSO and advanced permissions.

    How much VPS resource does n8n automation need?
    For light use — a handful of workflows firing a few times an hour — 1 vCPU and 1GB RAM is enough. Once you add Postgres and expect webhook bursts, 2 vCPUs and 4GB RAM is a safer baseline.

    Can n8n automation replace Zapier entirely?
    For most use cases, yes, especially anything involving webhooks, custom code, or high execution volume. The main gap is the breadth of pre-built app integrations — Zapier’s app catalog is larger, though n8n’s HTTP Request node covers any API with documented endpoints.

    Is n8n automation secure enough for handling API credentials?
    Credentials stored in n8n are encrypted at rest using the N8N_ENCRYPTION_KEY. Security in practice depends on how you deploy it — TLS, basic auth, and restricted network access (as covered above) are what actually keep it safe, not the encryption alone.

    How do I move n8n automation workflows between servers?
    Export workflows as JSON from the UI (or via the CLI with n8n export:workflow) and import them on the target instance. Credentials aren’t included in exports by default for security reasons, so you’ll need to recreate those manually on the new instance.

    Does n8n automation support webhooks that need to respond immediately?
    Yes — set the Webhook node’s response mode to “Immediately” if the caller needs a fast acknowledgment, then continue processing asynchronously in the rest of the workflow. This is essential for integrations with strict timeout limits, like many payment webhooks.

  • Dockerfile vs Docker Compose: Key Differences Explained

    Dockerfile vs Docker Compose: What’s the Real Difference?

    Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

    If you’re new to containers, the confusion between a Dockerfile and Docker Compose is almost a rite of passage. Both files live in your project, both start with the word “Docker,” and both seem to do something with building and running containers. But they solve different problems, and understanding that difference is the key to writing clean, maintainable Docker setups.

    This guide breaks down exactly what a Dockerfile does, what Docker Compose does, when you need one versus both, and how to avoid the mistakes most beginners make when moving from a single container to a full multi-service stack.

    What a Dockerfile Actually Does

    A Dockerfile is a plain-text script of instructions that tells Docker how to build a single image. Think of it as a recipe: start with a base layer, install dependencies, copy in your code, and define what happens when a container starts. Every line becomes a cached layer, and Docker builds those layers in order.

    Here’s a minimal Dockerfile for a Node.js API:

    FROM node:20-alpine
    
    WORKDIR /app
    
    COPY package*.json ./
    RUN npm ci --omit=dev
    
    COPY . .
    
    EXPOSE 3000
    CMD ["node", "server.js"]

    You build it with:

    docker build -t my-node-api:1.0 .

    And run it with:

    docker run -d -p 3000:3000 --name node-api my-node-api:1.0

    That’s the entire scope of a Dockerfile: define one image. It doesn’t know or care about other containers, networks, volumes, or environment-specific configuration beyond what’s baked in at build time or passed via docker run flags. If your application is just one container talking to nothing else, a Dockerfile alone is enough.

    Why Layer Caching Matters

    Docker caches each instruction as a layer. If you change a line further down the file, only that layer and everything after it gets rebuilt — everything above is reused from cache. This is why the COPY package*.json ./ and RUN npm ci steps happen before COPY . . in the example above. Your dependencies rarely change, but your source code changes constantly. Ordering instructions from least-to-most volatile can cut build times from minutes to seconds during active development.

    What Docker Compose Actually Does

    Docker Compose operates one layer up. Instead of describing how to build a single image, it describes how to run and connect multiple containers — services, networks, volumes, and environment variables — using a single declarative YAML file: docker-compose.yml (or compose.yaml in newer syntax).

    Here’s a realistic example: a Node.js API, a PostgreSQL database, and Redis for caching.

    version: "3.9"
    
    services:
      api:
        build: .
        ports:
          - "3000:3000"
        environment:
          - DATABASE_URL=postgres://appuser:apppass@db:5432/appdb
          - REDIS_URL=redis://cache:6379
        depends_on:
          - db
          - cache
    
      db:
        image: postgres:16-alpine
        environment:
          - POSTGRES_USER=appuser
          - POSTGRES_PASSWORD=apppass
          - POSTGRES_DB=appdb
        volumes:
          - db_data:/var/lib/postgresql/data
    
      cache:
        image: redis:7-alpine
    
    volumes:
      db_data:

    One command spins up the entire stack:

    docker compose up -d

    Notice the build: . line under the api service. That’s the connection point — Compose doesn’t replace the Dockerfile, it calls it. Compose builds the api image using the Dockerfile in the current directory, then runs it alongside the db and cache containers, automatically wiring them together on a shared internal network where each service can reach the others by name (db, cache).

    The Networking Piece Nobody Mentions

    One of the biggest reasons teams adopt Compose isn’t just convenience — it’s networking. Every docker compose up creates a dedicated bridge network for that project, and container names become resolvable hostnames inside it. That’s why the example above uses db:5432 instead of an IP address. If you tried to replicate this with plain docker run commands, you’d need to manually create a network with docker network create and attach every container to it with --network, then track IPs or aliases yourself. Compose handles all of that from a single file, which is a big part of why it fits so well into local development and small production deployments alike.

    Dockerfile vs Docker Compose: Side-by-Side

    | | Dockerfile | Docker Compose |
    |—|—|—|
    | Purpose | Build one image | Orchestrate multiple containers |
    | Format | Instruction script | Declarative YAML |
    | Scope | Single service | Full application stack |
    | Networking | None built-in | Automatic shared network |
    | Command | docker build | docker compose up |
    | Typical use | Defining an app’s runtime environment | Local dev environments, small multi-service deployments |

    The short version: a Dockerfile answers “how do I package this one application?” Docker Compose answers “how do multiple packaged applications run together?” You’ll almost never choose one instead of the other in a real project — you’ll write a Dockerfile for each custom service, then reference those Dockerfiles from a Compose file that ties everything together with prebuilt images like Postgres and Redis.

    Common Beginner Mistakes

  • Putting build logic inside docker-compose.yml. Compose can technically inline some build args, but application build steps (installing dependencies, compiling code) belong in the Dockerfile, not YAML.
  • Hardcoding secrets in either file. Use .env files with Compose’s env_file: directive instead of committing credentials to version control.
  • Rebuilding unnecessarily. Running docker compose up alone won’t rebuild your image after a Dockerfile change — you need docker compose up --build or docker compose build first.
  • Skipping .dockerignore. Without it, COPY . . in your Dockerfile will pull in node_modules, .git, and other bloat, slowing builds and inflating image size.
  • Using latest tags in production. Pin explicit versions (postgres:16-alpine, not postgres:latest) so a base image update doesn’t silently break your stack.
  • If you’re still fuzzy on how images and containers relate at a lower level, our beginner’s guide to Docker containers covers the fundamentals before you dive into multi-service setups. And once your Compose stack grows past a handful of services, it’s worth reading up on Docker networking modes to understand bridge, host, and overlay networks in more depth.

    When You Only Need a Dockerfile

    If you’re shipping a single stateless service — a static site, a small API with no database, a CLI tool packaged as a container — a Dockerfile is all you need. Add Compose and you’re adding a layer of abstraction (and a file to maintain) for zero actual benefit. Keep it simple:

    docker build -t my-tool .
    docker run --rm my-tool

    When You Need Both

    Any app with more than one moving part — a web server plus a database, a worker queue plus a cache, a frontend plus a backend plus a reverse proxy — benefits from Compose. It replaces long, error-prone docker run commands with a single reproducible file that any teammate (or your future self) can spin up with one command. This is also where Compose shines for local development: your entire stack — including database seed data via volumes — starts and stops in seconds, matching production topology closely enough to catch integration bugs early.

    For production, Compose still works well for small-to-medium deployments on a single VPS. If you’re scaling past one host or need rolling updates and self-healing, that’s when tools like Kubernetes or Docker Swarm enter the picture — but for most side projects, SaaS MVPs, and small business apps, Compose on a well-sized VPS is genuinely enough. Both DigitalOcean and Hetzner publish solid reference docs on running Compose stacks on their droplets/servers, and either is a reasonable starting point if you’re picking infrastructure.

    Deploying a Compose Stack to a VPS

    A typical low-friction production setup looks like this:

    # On your VPS
    git clone https://github.com/yourorg/yourapp.git
    cd yourapp
    cp .env.example .env   # fill in real secrets
    docker compose up -d --build

    Pair that with a process to pull the latest image on deploy, and you have a repeatable, low-maintenance pipeline without needing a full orchestration platform. If uptime matters for that stack, it’s worth wiring in external monitoring — a service like BetterStack can alert you the moment a container health check starts failing, which is far better than finding out from an angry user. For securing the public-facing side of that VPS, Cloudflare in front of your reverse proxy adds DDoS protection and free TLS with minimal setup.

    Once your Compose-based deployment is live, our guide to managing containers with Portainer is a good next read if you want a visual dashboard on top of the CLI.

    Quick Reference Commands

  • docker build -t name . — build an image from a Dockerfile
  • docker run -p 3000:3000 name — run a single container from an image
  • docker compose up -d — start all services defined in docker-compose.yml, detached
  • docker compose down — stop and remove containers, networks (add -v to also remove volumes)
  • docker compose logs -f api — tail logs for a specific service
  • docker compose exec api sh — open a shell inside a running service container
  • Recommended: Ready to put this into practice? DigitalOcean is a tool we use for exactly this, and we have a real, disclosed affiliate relationship with them.

    FAQ

    Do I need a Dockerfile if I’m using Docker Compose?
    Only for services you’re building yourself. Prebuilt images like postgres or redis don’t need a Dockerfile — Compose pulls them directly from a registry. You only write a Dockerfile for custom application code that Compose then references via build: ..

    Can Docker Compose replace a Dockerfile entirely?
    No. Compose can reference an existing image, but it can’t build a custom image from source code without a Dockerfile (or an equivalent build: context). They’re complementary, not interchangeable.

    Is docker-compose different from docker compose?
    Yes, slightly. docker-compose (with a hyphen) is the older standalone Python tool. docker compose (space, no hyphen) is the newer Go-based plugin built into the Docker CLI. Functionality is nearly identical, but the plugin version is actively maintained and recommended for new projects.

    Should I use Docker Compose in production?
    For small-to-medium single-host deployments, yes — it’s simple, reliable, and well-documented. For multi-host, auto-scaling, or high-availability needs, look at Kubernetes or Docker Swarm instead.

    What’s the difference between docker-compose.yml and compose.yaml?
    They’re functionally the same; compose.yaml is the newer preferred filename per the Compose Specification, but Docker still auto-detects either name.

    Can I use environment variables across both files?
    Yes. Compose reads a .env file in the same directory automatically and can pass those values into both the environment: section and build args for the Dockerfile via args: under build:.

    Final Takeaway

    A Dockerfile packages one application. Docker Compose orchestrates many. Nearly every real-world project uses both: Dockerfiles for each custom service, and a Compose file to wire those services together with databases, caches, and networking — all from one command. Once you internalize that division of labor, the rest of the Docker ecosystem — volumes, networks, multi-stage builds — starts making a lot more sense.

  • Docker Compose Rebuild: Complete Guide & Best Tips

    Docker Compose Rebuild: How to Rebuild Containers the Right Way

    Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

    If you’ve ever edited a Dockerfile or changed a dependency in requirements.txt or package.json and then run docker compose up, only to find your container is still running the old code, you’ve hit the single most common Docker Compose gotcha: Compose doesn’t rebuild images automatically just because the underlying files changed. You have to tell it to rebuild, and there’s more than one way to do that — each with different tradeoffs around speed, cache usage, and which services get touched.

    This guide covers every practical way to do a docker compose rebuild, when to use --build versus docker compose build, how to force a truly clean rebuild with --no-cache, how to rebuild a single service without restarting your entire stack, and the pitfalls that trip up even experienced engineers. We’ll also touch on wiring rebuilds into CI/CD so you’re not doing this by hand forever.

    Why You Need to Rebuild Docker Compose Services

    Docker images are built once from a Dockerfile and then cached as layers. docker compose up will happily start a container from an existing image even if the source code, dependencies, or base image have changed since that image was built. Compose only rebuilds automatically in a couple of narrow cases (like when the image doesn’t exist yet). Everything else — a code change, a new apt-get install, an updated FROM line — requires an explicit rebuild step.

    This is different from bind-mounted development setups where your code changes are reflected live because the host directory is mounted into the container. If you’re relying on volumes for hot-reload, you may not need a rebuild at all for code changes — just Compose configuration changes. But for anything baked into the image itself, a rebuild is mandatory.

    What “Rebuild” Actually Means in Docker Compose

    When people say “rebuild,” they usually mean one of two distinct operations:

    1. Rebuilding the image — re-running the instructions in your Dockerfile (or a subset of them, depending on the Docker layer cache) to produce a new image.
    2. Recreating the container — tearing down the running container and starting a new one, which is necessary even after a successful image rebuild, because a running container is tied to the image ID it started from.

    docker compose up --build does both in sequence: it rebuilds any image whose build context has an associated build: key in your compose.yaml, then recreates and starts the containers using the fresh images. Understanding this two-step nature is key to debugging rebuild issues later.

    docker compose build vs. up –build

    The cleanest way to just rebuild images without touching running containers is docker compose build:

    # Rebuild all services defined with a `build:` key
    docker compose build
    
    # Rebuild only one service
    docker compose build web
    
    # Rebuild and show full build output (no truncated logs)
    docker compose build --progress=plain

    This command only builds images — it does not start or restart anything. That makes it ideal for CI pipelines where you want to build and push an image before deployment, separate from the runtime step.

    If you want to rebuild and immediately bring the stack up with the new images, use:

    docker compose up --build

    This is the command most developers reach for during local development. It rebuilds any stale images, recreates containers that depend on them, and leaves unaffected services alone. Add -d to run detached:

    docker compose up --build -d

    One subtlety: --build still respects the Docker layer cache. If your Dockerfile hasn’t changed and neither has anything copied into the image (like COPY . .), Compose may reuse cached layers even with --build passed. That’s usually what you want — it’s fast — but it can bite you if you’ve changed a file that Docker’s cache invalidation didn’t catch, which brings us to the next point.

    Forcing a Clean Rebuild with –no-cache

    Sometimes the layer cache lies to you — a base image was updated remotely, a package version got pinned but not bumped, or you’re debugging a build that behaves inconsistently across machines. In those cases, force Docker to ignore the cache entirely:

    docker compose build --no-cache

    Combine it with --pull to also fetch the latest version of any base images referenced in FROM lines, rather than reusing a stale local copy:

    docker compose build --no-cache --pull

    Then bring the stack up with the freshly built images:

    docker compose up -d --force-recreate

    --force-recreate is worth calling out separately — it recreates containers even if Compose thinks nothing changed, which is useful when you’ve modified environment variables or mounted config files but the image itself is identical. For the full command reference, see the official Docker Compose CLI documentation.

    A full clean-slate rebuild, useful when things are genuinely broken and you want zero ambiguity, looks like this:

    docker compose down
    docker compose build --no-cache --pull
    docker compose up -d

    Be aware --no-cache rebuilds every layer from scratch, which can take significantly longer for images with large dependency installs. Don’t reach for it as your default — reserve it for when you suspect cache poisoning or inconsistent build behavior.

    Rebuilding a Single Service Without Touching the Rest

    In a multi-service stack — say, a web app, a worker, a Postgres database, and Redis — you rarely want to rebuild everything just because you changed one Dockerfile. Target the specific service by name:

    docker compose build worker
    docker compose up -d --no-deps worker

    --no-deps is important here. Without it, Compose will also recreate any services listed under depends_on for that service, which can cause unnecessary downtime for your database or cache layer. If you only want the one container recreated with its fresh image, --no-deps keeps the blast radius contained.

    For a related walkthrough on when to use up versus run for one-off commands against a service, see our guide on docker compose up vs run.

    Common Docker Compose Rebuild Pitfalls

    A handful of mistakes account for most of the “I rebuilt it but nothing changed” reports:

  • Editing a Dockerfile but running plain docker compose up — without --build, Compose reuses the existing image, no matter how recently the Dockerfile changed.
  • Assuming --build bypasses the cache — it doesn’t. --build triggers a build, but the Docker layer cache still applies unless you add --no-cache.
  • COPY ordering that invalidates cache too aggressively — if COPY . . happens before RUN npm install or RUN pip install -r requirements.txt, every source change forces a full dependency reinstall. Copy dependency manifests first, install, then copy the rest of the source.
  • Stale volumes masking a rebuild — if a named volume is mounted over /app or /node_modules, a rebuilt image’s files can be shadowed by old volume data. Run docker compose down -v (carefully — this deletes volume data) if you suspect this.
  • Forgetting --no-deps — rebuilding one service accidentally restarts dependent services, causing unnecessary connection drops for your database or message queue.
  • Confusing image tags across environments — if your compose.yaml references image: myapp:latest and you build locally but deploy a pulled image remotely, the rebuild only affects your local tag, not what’s running elsewhere.
  • If your stack also touches disk space issues from repeated rebuilds, our Docker cleanup and prune guide covers reclaiming space from dangling images and build cache without nuking things you still need.

    Automating Rebuilds in CI/CD

    Manual rebuilds are fine for local development, but production deployments should treat image builds as a discrete, versioned step — not something that happens implicitly on the server. A typical pattern:

    # CI step: build and tag with the commit SHA
    docker compose build
    docker tag myapp_web:latest registry.example.com/myapp:$(git rev-parse --short HEAD)
    docker push registry.example.com/myapp:$(git rev-parse --short HEAD)

    On the deployment server, you then pull the pre-built image rather than rebuilding from source, which avoids surprises from a build environment drifting between CI and production. The official Docker build command reference documents the underlying flags Compose wraps, which is useful when you need finer control than Compose exposes directly, like --build-arg or multi-platform builds with buildx.

    If you’re hosting the Compose stack itself on a VPS, a droplet from DigitalOcean gives you predictable resources for running your build and deploy pipeline without fighting shared-tenancy noisy neighbors. And once your rebuild-and-deploy pipeline is live, pairing it with uptime monitoring from BetterStack means you’ll get paged the moment a bad rebuild takes a service down, rather than finding out from a user complaint.

    Verifying a Rebuild Actually Took Effect

    After any rebuild, confirm the running container is actually using the new image rather than trusting that the command succeeded silently:

    docker compose images
    docker compose ps
    docker inspect --format='{{.Created}}' $(docker compose images -q web)

    The Created timestamp should match your rebuild time. If it doesn’t, you’re still running a stale image, and it’s worth checking whether the service actually has a build: key defined in compose.yaml at all — Compose can’t rebuild an image it was never told how to build; it will just keep pulling the same tagged image from a registry instead.

    Recommended: Ready to put this into practice? DigitalOcean is a tool we use for exactly this, and we have a real, disclosed affiliate relationship with them.

    FAQ

    Q: Does docker compose up rebuild images automatically?
    A: No. docker compose up only builds an image if it doesn’t already exist locally. If the image exists — even if it’s outdated — Compose will use it as-is. You need --build to force a rebuild check.

    Q: What’s the difference between docker compose build and docker compose up --build?
    A: docker compose build only builds images and does not start containers. docker compose up --build builds images first, then creates and starts containers from them. Use build alone in CI pipelines and up --build for local development loops.

    Q: Why doesn’t --no-cache seem to make a difference?
    A: Check that the service actually has a build: block in your compose.yaml. If it only has an image: key pointing at a registry image, Compose has nothing to build — it will just pull, and --no-cache has no effect on pulls.

    Q: How do I rebuild without restarting my database container?
    A: Rebuild and recreate only the target service with docker compose build <service> followed by docker compose up -d --no-deps <service>. This avoids cascading restarts to dependencies listed under depends_on.

    Q: Will rebuilding delete my database data?
    A: No, as long as your data lives in a named volume or bind mount rather than the container’s writable layer. Rebuilding the image and recreating the container doesn’t touch volumes unless you explicitly run docker compose down -v.

    Q: How can I speed up slow rebuilds?
    A: Order your Dockerfile so dependency installation happens before copying source code, use .dockerignore to exclude unnecessary files from the build context, and avoid --no-cache unless you specifically need a clean build — the layer cache exists precisely to make repeat rebuilds fast.

  • Docker Compose Env: Manage Variables the Right Way

    Docker Compose Env: A Complete Guide to Managing Environment Variables

    Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

    If you’ve spent any time building multi-container applications, you already know that hardcoding configuration values into your docker-compose.yml is a fast track to pain. Passwords end up in git history, staging and production drift apart, and every new team member has to guess which values actually matter. Getting docker compose env handling right — cleanly, securely, and predictably — is one of the highest-leverage things you can do for a Compose-based stack.

    This guide covers every practical way Compose lets you inject environment variables into containers: the environment key, .env files, the env_file directive, shell interpolation, and the precedence rules that decide which value wins when they collide. We’ll also cover common mistakes, security considerations, and how this fits into a broader Docker Compose networking guide if you’re building out a full stack.

    Why Environment Variables Matter in Compose

    Environment variables are the standard way to configure containerized applications without baking configuration into the image itself. This follows the twelve-factor app methodology, which recommends storing config in the environment rather than in code. Docker Compose gives you several mechanisms to do this, and understanding when to use each one saves you from a lot of “why isn’t this variable set” debugging sessions.

    The environment Key

    The most direct way to set variables is the environment key inside a service definition:

    services:
      web:
        image: myapp:latest
        environment:
          - NODE_ENV=production
          - API_PORT=3000
          - DEBUG=false

    You can also write it as a map instead of a list:

    services:
      web:
        image: myapp:latest
        environment:
          NODE_ENV: production
          API_PORT: 3000
          DEBUG: "false"

    Both forms are equivalent. The map form is often easier to read in larger files, and it avoids quoting ambiguity around booleans and numbers since YAML will coerce unquoted values.

    You can also pass through variables from your shell without hardcoding a value:

    services:
      web:
        environment:
          - API_KEY

    If API_KEY is exported in your shell, Compose passes that value straight into the container. This is useful for secrets you don’t want sitting in the compose file at all.

    Using .env Files for Variable Substitution

    Compose automatically reads a file named .env in the same directory as your docker-compose.yml and uses it for variable interpolation inside the compose file itself — not for injecting variables directly into containers. That distinction trips up a lot of people.

    A typical .env file looks like this:

    POSTGRES_USER=appuser
    POSTGRES_PASSWORD=supersecret
    POSTGRES_DB=appdb
    APP_PORT=8080

    You reference these values inside docker-compose.yml using ${VARIABLE} syntax:

    services:
      db:
        image: postgres:16
        environment:
          POSTGRES_USER: ${POSTGRES_USER}
          POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
          POSTGRES_DB: ${POSTGRES_DB}
        ports:
          - "${APP_PORT}:5432"

    This lets you keep one compose file and swap out .env contents per environment (dev, staging, prod) without touching YAML. You can verify how Compose resolves these values before actually starting containers:

    docker compose config

    This command prints the fully resolved configuration, with every ${VAR} substitution applied — extremely useful for debugging why a container isn’t getting the value you expect.

    The env_file Directive

    Where .env handles substitution in the compose file, env_file loads variables directly into a specific service’s container environment:

    services:
      api:
        image: myapp:latest
        env_file:
          - .env.api
          - .env.secrets

    This is the cleanest option when you have many variables, since you avoid listing them one by one under environment. It also lets different services load entirely different variable sets, which environment alone can’t do as elegantly. A typical .env.api might contain:

    LOG_LEVEL=info
    CACHE_TTL=300
    FEATURE_FLAG_NEW_UI=true

    Keep in mind that values loaded via env_file are not available for ${} interpolation elsewhere in the compose file — only the root .env file gets that treatment.

    Precedence: Which Value Actually Wins?

    When the same variable is defined in multiple places, Compose applies a strict precedence order, from highest to lowest priority:

  • Values set with docker compose run -e VAR=value on the command line
  • Variables defined directly under the environment key in the service
  • Variables loaded through env_file
  • Variables already set in the container image (via ENV in the Dockerfile)
  • Variables exported in the shell that runs docker compose, when referenced via ${VAR}
  • In practice this means environment always overrides env_file, so if you’re debugging a mysterious value that won’t change, check whether it’s hardcoded under environment somewhere overriding your .env.api file.

    Overriding Variables per Environment with Multiple Compose Files

    A common pattern for managing dev vs. production differences is layering compose files:

    docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

    Your base file defines shared services, and docker-compose.prod.yml overrides just the pieces that differ:

    services:
      web:
        environment:
          NODE_ENV: production
        env_file:
          - .env.production

    This avoids maintaining two nearly identical full compose files and keeps environment-specific values isolated. For a deeper look at structuring multi-file Compose projects, see our guide to Docker Compose networking, which covers how these override patterns interact with custom networks.

    Common Mistakes to Avoid

    A handful of mistakes account for most of the “my env vars aren’t working” support requests:

  • Committing .env to version control. If it contains secrets, it belongs in .gitignore, with a .env.example template committed instead.
  • Assuming .env variables reach the container automatically. They only drive interpolation in the compose file — use environment or env_file to actually inject them.
  • Quoting issues in .env files. Compose does not process shell-style quoting the way bash does; VALUE="hello world" will include the literal quote characters. Leave values unquoted unless you specifically need them.
  • Forgetting to restart containers after changing .env. Compose doesn’t hot-reload environment values — you need docker compose up -d --force-recreate or at least a restart of the affected service.
  • Mixing up build-time and run-time variables. ARG in a Dockerfile is only available during the image build; it has nothing to do with the runtime environment block unless you explicitly pass it through with ENV.
  • Debugging Environment Variables Inside a Running Container

    Sometimes the fastest way to confirm what a container actually received is to just ask it. docker compose exec lets you run commands inside a running service:

    docker compose exec web printenv

    This prints every environment variable Compose actually passed to that container’s process — after all .env interpolation, environment overrides, and env_file merging have already happened. If a specific variable is missing, grep for it directly:

    docker compose exec web printenv | grep API_KEY

    For a container that crashes before you can exec into it, add a temporary sleep or check the logs with docker compose logs web to see if the application logged a missing-variable error on startup. Combining this with docker compose config — which shows what Compose resolved before the container even starts — usually narrows the problem down to either a compose-level substitution issue or an application-level parsing issue.

    Security Considerations

    Environment variables are convenient, but they’re not a secure secrets store. Anything set via environment or env_file is visible to anyone who can run docker inspect or exec into the container and read /proc/1/environ. For genuinely sensitive values — API keys, database passwords, TLS private keys — consider Docker secrets in Swarm mode, or a dedicated secrets manager, rather than relying purely on plaintext .env files.

    If you’re self-hosting your Compose stack, the underlying infrastructure matters just as much as the compose configuration. Running your containers on a provider like DigitalOcean gives you predictable, isolated droplets where you control firewall rules around which ports and services are actually exposed — critical when a misconfigured environment block accidentally binds something to 0.0.0.0. And once your stack is live, pairing it with uptime and log monitoring through a service like BetterStack means you’ll catch a broken deployment (say, a service that silently fails to start because a required env var is missing) within minutes instead of discovering it from a support ticket.

    If you’re new to Compose generally, our beginner’s guide to environment variables in Linux is a good primer on how shell-level exports interact with anything Compose eventually passes to a container.

    Recommended: Want to explore DigitalOcean yourself? DigitalOcean is a direct vendor link (not an affiliate/tracked link).

    FAQ

    Q: Does Compose automatically load .env from any directory?
    A: No. Compose only reads a .env file located in the same directory as the docker-compose.yml file you’re running, or the directory you specify with --project-directory. It does not search parent directories.

    Q: Can I use a different filename instead of .env?
    A: Yes, with docker compose --env-file .env.staging up -d. This only changes which file Compose uses for interpolation — it doesn’t affect env_file directives inside services, which you set explicitly per service.

    Q: Why does my variable show up as an empty string instead of failing?
    A: Compose treats an undefined variable referenced via ${VAR} as an empty string by default rather than erroring. Use ${VAR:?error message} syntax to make Compose fail loudly if a required variable is missing.

    Q: How do I check what values Compose is actually resolving?
    A: Run docker compose config to print the fully rendered configuration after all substitutions are applied. This is the fastest way to debug a misbehaving variable.

    Q: Should secrets go in environment or env_file?
    A: Neither is truly secure — both end up as plaintext inside the container’s environment. For production secrets, prefer Docker secrets, a vault service, or your cloud provider’s secret manager, and reserve environment/env_file for non-sensitive configuration.

    Q: Can I set default values if a variable isn’t defined?
    A: Yes. Use ${VAR:-default} syntax in your compose file, e.g. ${APP_PORT:-8080}, which falls back to 8080 if APP_PORT is unset or empty.

    Wrapping Up

    Getting comfortable with the interplay between environment, .env, and env_file removes an entire category of “works on my machine” bugs from your Compose workflow. Start with docker compose config any time behavior looks wrong, keep secrets out of files that touch version control, and layer compose files rather than duplicating them for environment-specific overrides. Once your configuration strategy is solid, the rest of your stack — networking, volumes, scaling — gets a lot easier to reason about.

  • Postgres Docker Compose: Full Setup Guide for 2026

    Postgres Docker Compose: The Complete Setup Guide

    Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

    Running PostgreSQL locally used to mean installing packages, fighting with version conflicts, and cleaning up leftover config files. With postgres docker compose, you get a reproducible, disposable database environment that starts with one command and leaves no trace on your host system when you’re done.

    This guide covers everything from a minimal single-container setup to a production-ready configuration with persistent volumes, health checks, environment-based secrets, and automated backups. If you’re deploying containerized apps regularly, pair this with our Docker networking guide for a deeper understanding of how services talk to each other inside Compose.

    Why Use Docker Compose for Postgres

    Docker Compose turns a multi-step manual setup into a declarative YAML file. Instead of remembering flags for docker run, you define the database service, its volumes, networks, and environment variables once, then bring the whole stack up or down with a single command.

    This matters for a few practical reasons:

  • Reproducibility — the same docker-compose.yml produces an identical environment on your laptop, your CI runner, and your staging server.
  • Isolation — Postgres runs in its own container with its own filesystem, so it won’t collide with a system-installed version or another project’s database.
  • Easy teardowndocker compose down -v wipes the database cleanly, which is ideal for testing migrations or resetting a dev environment.
  • Multi-service orchestration — you can add your app server, Redis, pgAdmin, or a reverse proxy to the same file and manage them together.
  • If you’re new to containers generally, our Docker for beginners walkthrough covers the fundamentals before you dive into Compose specifics.

    Prerequisites

    Before starting, make sure you have:

  • Docker Engine 20.10+ installed
  • Docker Compose v2 (bundled with Docker Desktop and modern Docker Engine installs, invoked as docker compose, not the old standalone docker-compose)
  • Basic familiarity with YAML syntax
  • A terminal and a text editor
  • Check your versions with:

    docker --version
    docker compose version

    If docker compose version fails, consult the official Docker Compose installation docs for your platform.

    Basic Postgres Docker Compose Setup

    Start with a minimal working example. Create a project directory and a docker-compose.yml file:

    mkdir postgres-compose-demo && cd postgres-compose-demo
    touch docker-compose.yml

    Add this configuration:

    services:
      db:
        image: postgres:16
        restart: unless-stopped
        environment:
          POSTGRES_USER: appuser
          POSTGRES_PASSWORD: changeme
          POSTGRES_DB: appdb
        ports:
          - "5432:5432"
        volumes:
          - pgdata:/var/lib/postgresql/data
    
    volumes:
      pgdata:

    Bring it up:

    docker compose up -d

    Verify the container is running and healthy:

    docker compose ps
    docker compose logs db

    Connect using psql from your host (if installed) or directly inside the container:

    docker compose exec db psql -U appuser -d appdb

    That’s a fully working Postgres instance with a named volume for persistence. The pgdata volume survives container restarts and recreation, so your data isn’t lost when you run docker compose down (without the -v flag).

    Understanding the Volume Mapping

    The line pgdata:/var/lib/postgresql/data maps a Docker-managed named volume to Postgres’s internal data directory. This is the single most important line in the file — without it, every docker compose down followed by up wipes your database clean.

    There are two common approaches:

  • Named volumes (recommended): Docker manages the storage location, and you don’t need to worry about host filesystem permissions. Use pgdata:/var/lib/postgresql/data as shown above.
  • Bind mounts: You map a specific host directory, e.g. ./data:/var/lib/postgresql/data. This gives you direct filesystem access but can run into UID/GID permission mismatches between your host user and the container’s postgres user (UID 999 in the official image).
  • For most setups, named volumes are simpler and less error-prone. Reserve bind mounts for cases where you need to inspect or back up raw data files directly from the host.

    Environment Variables and Secrets

    Hardcoding passwords in docker-compose.yml is a common mistake that gets committed to git accidentally. Instead, use a .env file:

    # .env
    POSTGRES_USER=appuser
    POSTGRES_PASSWORD=a-much-stronger-password-here
    POSTGRES_DB=appdb

    Reference it in your Compose file:

    services:
      db:
        image: postgres:16
        restart: unless-stopped
        env_file:
          - .env
        ports:
          - "5432:5432"
        volumes:
          - pgdata:/var/lib/postgresql/data
    
    volumes:
      pgdata:

    Add .env to your .gitignore immediately:

    echo ".env" >> .gitignore

    For production deployments, consider Docker secrets or a dedicated secrets manager instead of plain .env files — environment variables are visible to anything that can inspect the running container (docker inspect), which is a real exposure risk on shared hosts.

    Adding Health Checks and a Custom Network

    A production-grade Compose file should confirm Postgres is actually accepting connections before dependent services (like your app) try to connect. Add a health check:

    services:
      db:
        image: postgres:16
        restart: unless-stopped
        env_file:
          - .env
        ports:
          - "5432:5432"
        volumes:
          - pgdata:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
          interval: 10s
          timeout: 5s
          retries: 5
        networks:
          - backend
    
      app:
        build: .
        depends_on:
          db:
            condition: service_healthy
        environment:
          DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
        networks:
          - backend
    
    volumes:
      pgdata:
    
    networks:
      backend:

    The depends_on.condition: service_healthy clause ensures your app container waits for Postgres to actually be ready, not just started. This eliminates the classic “connection refused” error on the first boot of a multi-container stack.

    Notice the app connects to db, not localhost or 127.0.0.1. Compose automatically creates a DNS entry for each service name on the shared network, so containers reach each other by service name.

    Backups and Data Recovery

    Even with persistent volumes, you need actual backups — a corrupted volume or accidental docker compose down -v can still destroy your data. Use pg_dump inside the running container:

    docker compose exec db pg_dump -U appuser appdb > backup.sql

    Restore into a fresh container:

    cat backup.sql | docker compose exec -T db psql -U appuser -d appdb

    For automated, scheduled backups, add a lightweight sidecar service using a cron-based image, or run pg_dump from a host cron job that shells into the container. If you’re managing this on a remote VPS, our Linux server hardening checklist covers securing SSH and cron jobs so scheduled backup scripts aren’t a security liability.

    For larger datasets, consider pg_basebackup for physical backups, which are faster to restore than logical pg_dump exports. The official PostgreSQL backup documentation covers the tradeoffs between logical and physical backup strategies in detail.

    Running Multiple Postgres Versions Side by Side

    One underrated benefit of Docker Compose is testing against multiple Postgres major versions without touching your host system. Just change the image tag per project:

    services:
      db-15:
        image: postgres:15
        ports:
          - "5433:5432"
        volumes:
          - pgdata15:/var/lib/postgresql/data
    
      db-16:
        image: postgres:16
        ports:
          - "5434:5432"
        volumes:
          - pgdata16:/var/lib/postgresql/data
    
    volumes:
      pgdata15:
      pgdata16:

    This is invaluable when validating an upgrade path before touching production. Spin up both versions, dump data from the old one, restore into the new one, and confirm your app behaves identically before committing to the upgrade.

    Choosing Where to Host Your Postgres + Compose Stack

    Once your Compose setup works locally, you’ll want to deploy it to a real server. A small VPS with a few gigabytes of RAM handles most small-to-medium Postgres workloads comfortably. Providers like DigitalOcean and Hetzner both offer affordable droplets/instances that run Docker Compose stacks well out of the box — DigitalOcean’s managed load balancers and snapshots are convenient if you want less manual ops work, while Hetzner tends to offer more raw compute per dollar if you’re comfortable managing more yourself.

    Whichever you choose, make sure to configure a firewall so port 5432 isn’t exposed to the public internet — bind it to 127.0.0.1:5432:5432 instead of 5432:5432 if only local services need access, and put Postgres behind a private network or SSH tunnel for remote administration.

    Recommended: Want to explore DigitalOcean yourself? DigitalOcean is a direct vendor link (not an affiliate/tracked link).

    FAQ

    Does Docker Compose persist Postgres data by default?
    No. Without an explicit volume mapping, data is stored inside the container’s writable layer and is lost when the container is removed. Always define a named volume or bind mount for /var/lib/postgresql/data.

    What’s the difference between docker compose down and docker compose down -v?
    docker compose down stops and removes containers and networks but leaves named volumes intact. Adding -v also deletes the volumes, which permanently erases your database data — use it carefully.

    Can I run pgAdmin alongside Postgres in the same Compose file?
    Yes. Add a pgadmin service using the dpage/pgadmin4 image on the same network as your db service, then connect using the service name db as the host in pgAdmin’s connection settings.

    Why does my app container fail to connect to Postgres on startup?
    This usually means the app tried to connect before Postgres finished initializing. Add a healthcheck to the db service and use depends_on.condition: service_healthy on the app service to fix the race condition.

    How do I change the Postgres password after the container has already initialized?
    Environment variables like POSTGRES_PASSWORD only apply on first initialization of an empty data directory. To change the password afterward, connect with psql and run ALTER USER appuser WITH PASSWORD 'newpassword'; directly.

    Is it safe to expose port 5432 publicly?
    Generally no. Bind the port to localhost only, or better, don’t publish it at all and let other containers reach Postgres over the internal Compose network by service name.

    Networking Between Services

    By default, Compose creates a private network for all services defined in the same file. The app service above reaches PostgreSQL simply by using db as the hostname — Docker’s internal DNS resolves it automatically. You don’t need to expose port 5432 to the host at all unless you want external tools (like a local GUI client) to connect directly.

    If you do need host access for debugging with a tool like pgAdmin or DBeaver, keep the port mapping but restrict it:

    ports:
      - "127.0.0.1:5432:5432"

    Binding to 127.0.0.1 instead of 0.0.0.0 prevents the database port from being exposed on your machine’s public network interface — a small change that matters a lot if you’re running this on a cloud VPS rather than a laptop.

    Multi-Container Example with pgAdmin

    For local development, it’s often convenient to run a web-based admin UI alongside PostgreSQL:

    services:
      db:
        image: postgres:16
        restart: unless-stopped
        env_file:
          - .env
        volumes:
          - pgdata:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
          interval: 10s
          timeout: 5s
          retries: 5
    
      pgadmin:
        image: dpage/pgadmin4:latest
        restart: unless-stopped
        environment:
          PGADMIN_DEFAULT_EMAIL: [email protected]
          PGADMIN_DEFAULT_PASSWORD: changeme
        ports:
          - "127.0.0.1:8080:80"
        depends_on:
          - db
    
    volumes:
      pgdata:

    Access pgAdmin at http://localhost:8080, then add a new server connection pointing to host db, port 5432, using the credentials from your .env file.

    Production Hardening Checklist

    Before you point real traffic at a containerized PostgreSQL instance, review these items:

  • Set strong, unique passwords and rotate them periodically.
  • Bind the host port to 127.0.0.1 or remove the port mapping entirely if only internal services need access.
  • Enable regular automated backups with off-host storage (S3, Backblaze, or similar).
  • Use restart: unless-stopped so the database recovers automatically after a host reboot.
  • Monitor disk usage on the volume — PostgreSQL doesn’t cap growth automatically.
  • Pin the image tag (postgres:16.3, not just postgres:latest) to avoid surprise upgrades.
  • Consider running PostgreSQL on a dedicated, appropriately sized VPS rather than co-locating it with CPU-heavy application containers.
  • If you’re hosting this yourself rather than using a managed database, providers like Hetzner offer solid price-to-performance ratios for dedicated database VPS instances, which matters since PostgreSQL performance is heavily tied to disk I/O and available RAM for caching.

    Resource Limits

    Compose lets you cap CPU and memory so a runaway query doesn’t starve other containers on the same host:

    services:
      db:
        image: postgres:16
        deploy:
          resources:
            limits:
              cpus: "2"
              memory: 2G

    Note that deploy.resources is fully honored under Docker Swarm; under plain docker compose up, Compose v2 does apply these limits on recent Docker Engine versions, but it’s worth verifying with docker stats after deployment rather than assuming it’s enforced.

    Common Errors and Fixes

    A few issues come up repeatedly when people first containerize PostgreSQL:

  • “role does not exist” — usually means the POSTGRES_USER env var wasn’t set before the volume was initialized for the first time. Environment variables only take effect on first container creation; changing them later has no effect on an existing volume.
  • “connection refused” — the health check isn’t wired up, and your app is connecting before PostgreSQL finishes booting.
  • Permission denied on data directory — usually a bind-mount UID mismatch between host and container. Named volumes avoid this entirely.
  • Data vanished after docker compose down — someone ran docker compose down -v and deleted the volume along with the containers.
  • Wrapping Up

    A well-structured docker-compose.yml turns Postgres from a fragile local install into a portable, version-controlled piece of infrastructure. Start with the minimal setup, add named volumes early, move secrets into a .env file, and layer on health checks once you’re connecting other services. From there, backups and a hardened network configuration are the last steps between a dev setup and something you’d trust in production.

    If you’re scaling this stack beyond a single server, revisit our Docker networking guide for multi-host and reverse proxy patterns that build directly on the concepts covered here.

  • Docker Compose Secrets: Secure Config Management Guide

    Docker Compose Secrets: How to Stop Leaking Credentials in Your Containers

    Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

    If you’ve ever pasted a database password directly into a docker-compose.yml file under environment:, you’ve already leaked it. It’s sitting in plaintext in your git history, your shell history, and probably a Slack thread from six months ago. Docker Compose secrets exist specifically to fix this problem, and most teams still aren’t using them correctly — or at all.

    This guide covers what Docker Compose secrets actually are, how they differ from environment variables, and how to wire them into real services like Postgres, Redis, and custom apps. We’ll also cover the common mistakes that make people think secrets “don’t work” in Compose (they usually do — you’re just using the wrong driver mode).

    Why Environment Variables Aren’t Enough

    The default instinct for passing a database password to a container is:

    services:
      db:
        image: postgres:16
        environment:
          POSTGRES_PASSWORD: hunter2

    This works, but it has real problems:

  • The value is visible in docker inspect <container> output to anyone with Docker socket access.
  • It’s committed to version control unless you’re careful with .gitignore, and even then it often ends up in CI logs.
  • It shows up in process listings and container logs if any tooling echoes environment variables during startup.
  • Every service that needs the secret gets it injected as a full environment variable, widening the blast radius if one container is compromised.
  • Docker Compose secrets solve this by mounting sensitive values as files inside the container’s filesystem, typically under /run/secrets/, rather than injecting them into the environment. Only the services that explicitly declare a dependency on a secret can read it, and the value never appears in docker inspect or docker compose config output.

    How Docker Compose Secrets Actually Work

    A secret in Compose is defined at the top level of your docker-compose.yml, then attached to individual services. There are two ways to source a secret:

    1. File-based — the secret’s contents come from a local file on the host.
    2. External — the secret already exists (commonly in Docker Swarm) and Compose just references it by name.

    Here’s the file-based approach, which is what most single-host and local dev setups use:

    services:
      db:
        image: postgres:16
        environment:
          POSTGRES_PASSWORD_FILE: /run/secrets/db_password
        secrets:
          - db_password
    
    secrets:
      db_password:
        file: ./secrets/db_password.txt

    Notice the POSTGRES_PASSWORD_FILE variable instead of POSTGRES_PASSWORD. The official Postgres image (and many others) supports a _FILE suffix convention: instead of reading the value directly from an environment variable, the entrypoint script reads it from the file path you provide. Inside the container, Compose mounts the secret at /run/secrets/db_password, readable only by root by default with 0444 permissions.

    Create the secret file locally:

    mkdir -p secrets
    echo "hunter2" > secrets/db_password.txt
    chmod 600 secrets/db_password.txt

    Then add secrets/ to your .gitignore immediately — this is the single most common way secrets leak back into git despite people setting up the whole system correctly.

    echo "secrets/" >> .gitignore

    Using Secrets in Your Own Application Code

    Not every image supports the _FILE convention, so for custom apps you’ll read the secret file directly. Here’s a minimal Node.js example:

    const fs = require('fs');
    
    function readSecret(name) {
      const path = `/run/secrets/${name}`;
      return fs.readFileSync(path, 'utf8').trim();
    }
    
    const dbPassword = readSecret('db_password');
    const apiKey = readSecret('third_party_api_key');

    And the equivalent Python pattern:

    from pathlib import Path
    
    def read_secret(name: str) -> str:
        return Path(f"/run/secrets/{name}").read_text().strip()
    
    db_password = read_secret("db_password")

    This pattern works identically whether you’re running plain docker compose up locally or deploying to a Swarm cluster, which makes it worth adopting even if you think you’ll never touch Swarm.

    Multiple Secrets Across Multiple Services

    A realistic stack usually needs several secrets shared across services with different access levels. Here’s a fuller example with Postgres, Redis with a password, and an app container that needs both plus a third-party API key:

    services:
      app:
        build: .
        depends_on:
          - db
          - cache
        secrets:
          - db_password
          - api_key
    
      db:
        image: postgres:16
        environment:
          POSTGRES_PASSWORD_FILE: /run/secrets/db_password
        secrets:
          - db_password
    
      cache:
        image: redis:7
        command: ["redis-server", "--requirepass-file", "/run/secrets/redis_password"]
        secrets:
          - redis_password
    
    secrets:
      db_password:
        file: ./secrets/db_password.txt
      redis_password:
        file: ./secrets/redis_password.txt
      api_key:
        file: ./secrets/api_key.txt

    Note that redis-server doesn’t natively support a --requirepass-file flag out of the box in older versions — check your image’s documentation, and if it’s unsupported, wrap the container’s entrypoint with a small shell script that reads the file and passes it as --requirepass "$(cat /run/secrets/redis_password)" instead.

    Each service only lists the secrets it actually needs under its own secrets: key. The app service can read db_password and api_key, but has no access to redis_password unless you explicitly add it — this is the principle of least privilege applied to container secrets.

    Secrets vs. .env Files: When to Use Which

    A lot of confusion comes from Compose also supporting .env files and env_file: for non-sensitive configuration. These are different tools for different jobs:

  • .env / env_file — good for non-sensitive configuration like LOG_LEVEL=debug or APP_PORT=3000. Values still land in the environment and are visible via docker inspect.
  • secrets: — good for anything sensitive: passwords, API keys, TLS private keys, tokens. Values are mounted as files, not injected into the process environment, and are excluded from docker compose config output when using external secret backends.
  • A reasonable rule: if leaking the value in a support ticket or screen share would be embarrassing or dangerous, it belongs in secrets:, not .env.

    For teams running this on a VPS rather than a laptop, pairing this setup with a hardened host matters just as much as the Compose file itself. If you’re provisioning that host yourself, a provider like DigitalOcean or Hetzner gives you a clean Ubuntu box to configure a proper firewall and non-root Docker setup on — we cover the baseline hardening steps in our Docker security checklist.

    Secrets in Docker Swarm vs. Plain Compose

    It’s worth being explicit about a limitation: in plain docker compose up (no Swarm), secrets are still just bind-mounted files under the hood, not encrypted at rest by Docker itself. The security benefit is about avoiding environment variable exposure and docker inspect leakage, not full encryption.

    In Docker Swarm mode, secrets are genuinely encrypted at rest in the Raft log and only decrypted in memory on the nodes running containers that need them. If you need that stronger guarantee, you’d deploy with docker stack deploy instead of docker compose up, using the same secrets: syntax:

    docker swarm init
    docker stack deploy -c docker-compose.yml mystack

    For most single-host deployments — a small VPS running a handful of services — plain Compose secrets are sufficient as long as you also lock down file permissions on the host and restrict who has SSH and Docker group access. For anything handling real customer data across multiple nodes, Swarm secrets or a dedicated secrets manager like HashiCorp Vault is the more defensible choice.

    Rotating Secrets Without Downtime

    One underrated benefit of file-based secrets is that rotation doesn’t require rebuilding your image. To rotate the db_password:

    echo "new-strong-password" > secrets/db_password.txt
    docker compose up -d --force-recreate db app

    This recreates only the affected containers with the new secret mounted, without touching unrelated services. Compare that to environment-variable-based secrets baked into a .env file referenced by many services — rotating those often means restarting everything and hoping you didn’t miss a reference somewhere.

    If you’re monitoring uptime during a rotation like this, a service like BetterStack will catch it immediately if a recreated container fails health checks, which is worth having in place before you start touching production credentials.

    Common Mistakes to Avoid

  • Committing the secrets file anyway. .gitignore only prevents future commits — if secrets/db_password.txt was ever committed, it’s in your git history forever unless you rewrite it with git filter-repo or BFG Repo-Cleaner.
  • Using POSTGRES_PASSWORD and POSTGRES_PASSWORD_FILE together. Some images will silently prefer one over the other, or error out. Pick one convention per variable.
  • Forgetting file permissions on the host. A secret file with 644 permissions readable by any local user defeats the purpose. Use chmod 600.
  • Assuming plain Compose secrets are encrypted at rest. They aren’t, only Swarm secrets are — treat host-level access control as the real security boundary for single-host setups.
  • Hardcoding fallback defaults in application code. If your app falls back to a hardcoded password when the secret file is missing, you’ve built a silent security hole that only shows up when something goes wrong with the mount.
  • We go into more depth on locking down the host itself, including firewall rules and non-root container users, in our companion piece on hardening a Docker host on a VPS.

    Recommended: Ready to put this into practice? DigitalOcean is a tool we use for exactly this, and we have a real, disclosed affiliate relationship with them.

    FAQ

    Do Docker Compose secrets work without Docker Swarm?
    Yes. File-based secrets work fine with plain docker compose up. They’re mounted as read-only files under /run/secrets/ inside the container. The main difference with Swarm is that Swarm additionally encrypts secrets at rest in its cluster state; plain Compose does not.

    Why isn’t my POSTGRES_PASSWORD_FILE being picked up?
    Check that you’re not also setting POSTGRES_PASSWORD in the same service — many official images check for the plain variable first and will ignore the _FILE variant, or throw an error if both are set. Also confirm the secret is actually listed under that service’s secrets: key, not just in the top-level secrets: block.

    Can I use environment variables to reference secret values directly?
    Not securely, no. If you do MY_SECRET: ${SOME_SECRET_VALUE} in your Compose file, the value still gets injected as a plain environment variable and shows up in docker inspect. The whole point of the secrets: mechanism is to avoid that path by mounting a file instead.

    How do I pass secrets in a CI/CD pipeline without writing them to disk?
    Most CI systems (GitHub Actions, GitLab CI) let you inject secrets as masked environment variables, then have your pipeline write them to the expected secret file paths just before running docker compose up, and clean them up afterward. Avoid echoing the values in any pipeline logs.

    Is it safe to store secrets files in a Docker volume instead of a bind mount?
    You can, but it adds complexity for little benefit in most single-host setups. A bind-mounted file on the host with restrictive permissions is simpler to audit and rotate than a value living inside a named volume.

    What happens to secrets when a container is removed?
    The mounted secret file inside the container is removed along with the container’s filesystem. The source file on the host (referenced in the file: path) is untouched, so recreating the container remounts the same secret unless you’ve changed the source file.

    Wrapping Up

    Docker Compose secrets aren’t complicated once you get past the initial unfamiliarity — the pattern is always: define the secret at the top level, attach it to the services that need it, and read it from /run/secrets/<name> inside the container instead of an environment variable. Combined with proper .gitignore hygiene, restrictive file permissions, and a hardened host, this closes off one of the most common and preventable ways credentials leak out of containerized applications.

    If you’re setting this up on a fresh VPS, take the time to also review basic firewall and SSH hardening before you deploy anything with real credentials attached — a secrets-aware Compose file doesn’t help much if the host itself is wide open.