<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aman Xavier</title>
    <description>The latest articles on DEV Community by Aman Xavier (@aman_xavier_d1b4d9b3f26b5).</description>
    <link>https://dev.to/aman_xavier_d1b4d9b3f26b5</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4186097%2F8c06d776-1045-4797-b858-d065af335598.jpg</url>
      <title>DEV Community: Aman Xavier</title>
      <link>https://dev.to/aman_xavier_d1b4d9b3f26b5</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aman_xavier_d1b4d9b3f26b5"/>
    <language>en</language>
    <item>
      <title>Deploy Django with Docker and GitHub Actions</title>
      <dc:creator>Aman Xavier</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:55:26 +0000</pubDate>
      <link>https://dev.to/aman_xavier_d1b4d9b3f26b5/deploy-django-with-docker-and-github-actions-8md</link>
      <guid>https://dev.to/aman_xavier_d1b4d9b3f26b5/deploy-django-with-docker-and-github-actions-8md</guid>
      <description>&lt;p&gt;By the end of this, every push to &lt;strong&gt;main&lt;/strong&gt; builds a fresh Docker image of your Django app, pushes it to GitHub's container registry, and restarts it on your server with migrations applied. No more SSH-ing in to &lt;strong&gt;git pull&lt;/strong&gt; by hand.&lt;/p&gt;

&lt;p&gt;What you need&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Django project with a &lt;strong&gt;requirements.txt&lt;/strong&gt; that includes &lt;strong&gt;gunicorn&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A Linux server (any VPS) with Docker and the Docker Compose plugin installed.&lt;/li&gt;
&lt;li&gt;A GitHub repository for the project.&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;The Dockerfile&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Put this in the project root. Change &lt;strong&gt;config.wsgi&lt;/strong&gt; to your project's WSGI module.&lt;/p&gt;

&lt;p&gt;dockerfile&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-slim&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PYTHONDONTWRITEBYTECODE=1 \&lt;/span&gt;
    PYTHONUNBUFFERED=1

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="c"&gt;# Collect static files into the image (needs STATIC_ROOT set in settings)&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;python manage.py collectstatic &lt;span class="nt"&gt;--noinput&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a &lt;strong&gt;.dockerignore&lt;/strong&gt; so you don't copy junk into the image:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.git
.env
__pycache__/
*.pyc
venv/
node_modules/
media/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;The compose file on the server&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On your server, create a folder such as &lt;strong&gt;/srv/myapp&lt;/strong&gt; with this &lt;strong&gt;docker-compose.yml&lt;/strong&gt; and a &lt;strong&gt;.env&lt;/strong&gt; file holding your secrets. Replace the image name with your own, in lowercase.&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/yourname/myapp:latest&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000:8000"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put a reverse proxy such as Nginx or Caddy in front of port 8000 for HTTPS. Caddy is the quickest, because it gets certificates for you automatically.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Repository secrets&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In your GitHub repo, go to Settings, then Secrets and variables, then Actions, and add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SERVER_HOST&lt;/strong&gt;: your server's IP or domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SERVER_USER&lt;/strong&gt;: the user that runs Docker on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SERVER_SSH_KEY&lt;/strong&gt;: a private SSH key whose public half is in that user's &lt;strong&gt;~/.ssh/authorized_keys&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;The workflow&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Save this as &lt;strong&gt;.github/workflows/deploy.yml&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;% raw %&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;

&lt;span class="s"&gt;yaml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/yourname/myapp:latest&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_HOST }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;cd /srv/myapp&lt;/span&gt;
            &lt;span class="s"&gt;docker compose pull web&lt;/span&gt;
            &lt;span class="s"&gt;docker compose up -d web&lt;/span&gt;
            &lt;span class="s"&gt;docker compose exec T web python manage.py migrate noinput&lt;/span&gt;

&lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;% endraw %&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Image names on GitHub's registry must be lowercase. If your GitHub username has capitals, type the lowercase name into &lt;strong&gt;tags&lt;/strong&gt; yourself rather than building it from &lt;strong&gt;github.repository&lt;/strong&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Let the server pull the image&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the package is private, log the server in to the registry once, using a GitHub personal access token with the &lt;strong&gt;read:packages&lt;/strong&gt; scope:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
&lt;code&gt;echo YOUR_TOKEN | docker login ghcr.io -u yourname --password-stdin&lt;/code&gt;&lt;br&gt;
Push and watch it deploy&lt;/p&gt;

&lt;p&gt;Commit everything and push to &lt;strong&gt;main&lt;/strong&gt;. Open the Actions tab on GitHub and you'll see the build, the push and the deploy run in order. From now on, merging to **main **is your deploy button.&lt;/p&gt;

&lt;p&gt;Common problems&lt;br&gt;
collectstatic fails during the build: set &lt;strong&gt;STATIC_ROOT&lt;/strong&gt; in settings, and make sure settings don't crash when secrets are missing at build time.&lt;br&gt;
Permission denied on SSH: the public key isn't in &lt;strong&gt;authorized_keys&lt;/strong&gt; for &lt;strong&gt;SERVER_USER&lt;/strong&gt;, or the key secret has extra spaces.&lt;br&gt;
Old version still running: check that the &lt;strong&gt;tags&lt;/strong&gt; in the workflow and the &lt;strong&gt;image&lt;/strong&gt; in compose are exactly the same name.&lt;/p&gt;

&lt;p&gt;How do you handle deploys on your side projects? Still SSH and &lt;strong&gt;git pull&lt;/strong&gt;, or something fancier? Tell me in the comments.&lt;/p&gt;

&lt;p&gt;I write more of these at amanxavier.com, mostly about Django, Next.js and building products from India.&lt;/p&gt;

</description>
      <category>django</category>
      <category>docker</category>
      <category>devops</category>
      <category>python</category>
    </item>
  </channel>
</rss>
