diff --git a/lib/mobilizon/posts/post.ex b/lib/mobilizon/posts/post.ex
index adf955989..a2028ccd5 100644
--- a/lib/mobilizon/posts/post.ex
+++ b/lib/mobilizon/posts/post.ex
@@ -96,6 +96,7 @@ defmodule Mobilizon.Posts.Post do
     |> TitleSlug.unique_constraint()
     |> maybe_generate_url()
     |> validate_required(@required_attrs -- [:slug, :url])
+    |> unique_constraint(:url)
   end
 
   defp maybe_generate_id(%Ecto.Changeset{} = changeset) do
diff --git a/priv/repo/migrations/20210622133516_cleanup_posts.exs b/priv/repo/migrations/20210622133516_cleanup_posts.exs
new file mode 100644
index 000000000..80831acfc
--- /dev/null
+++ b/priv/repo/migrations/20210622133516_cleanup_posts.exs
@@ -0,0 +1,73 @@
+defmodule Mobilizon.Storage.Repo.Migrations.CleanupPosts do
+  use Ecto.Migration
+
+  def up do
+    # Make sure we don't have any duplicate posts
+    rows = fetch_bad_rows()
+    Enum.each(rows, &process_row/1)
+  end
+
+  def down do
+    # No way down
+  end
+
+  defp fetch_bad_rows() do
+    %Postgrex.Result{rows: rows} =
+      Ecto.Adapters.SQL.query!(
+        Mobilizon.Storage.Repo,
+        "SELECT * FROM (
+          SELECT id, url,
+          ROW_NUMBER() OVER(PARTITION BY url ORDER BY id asc) AS Row
+          FROM posts
+        ) dups
+        WHERE dups.Row > 1;"
+      )
+
+    rows
+  end
+
+  defp process_row([id, url, _row]) do
+    first_id = find_first_post_id(url)
+
+    if id != first_id do
+      repair_post_medias(id, first_id)
+      repair_post_tags(id, first_id)
+      delete_row(id)
+    end
+  end
+
+  defp find_first_post_id(url) do
+    %Postgrex.Result{rows: [[id]]} =
+      Ecto.Adapters.SQL.query!(
+        Mobilizon.Storage.Repo,
+        "SELECT id FROM posts WHERE url = $1 order by inserted_at asc limit 1",
+        [url]
+      )
+
+    id
+  end
+
+  defp repair_post_medias(id, first_id) do
+    Ecto.Adapters.SQL.query!(
+      Mobilizon.Storage.Repo,
+      "UPDATE post_medias SET post_id = $1 WHERE post_id = $2",
+      [first_id, id]
+    )
+  end
+
+  defp repair_post_tags(id, first_id) do
+    Ecto.Adapters.SQL.query!(
+      Mobilizon.Storage.Repo,
+      "UPDATE post_tags SET post_id = $1 WHERE post_id = $2",
+      [first_id, id]
+    )
+  end
+
+  defp delete_row(id) do
+    Ecto.Adapters.SQL.query!(
+      Mobilizon.Storage.Repo,
+      "DELETE FROM posts WHERE id = $1",
+      [id]
+    )
+  end
+end
diff --git a/priv/repo/migrations/20210622133555_add_index_to_posts.exs b/priv/repo/migrations/20210622133555_add_index_to_posts.exs
new file mode 100644
index 000000000..8fecd97ce
--- /dev/null
+++ b/priv/repo/migrations/20210622133555_add_index_to_posts.exs
@@ -0,0 +1,11 @@
+defmodule Mobilizon.Storage.Repo.Migrations.AddIndexToPosts do
+  use Ecto.Migration
+
+  def up do
+    create_if_not_exists(unique_index("posts", [:url]))
+  end
+
+  def down do
+    drop_if_exists(index("posts", [:url]))
+  end
+end
diff --git a/test/mobilizon/posts_test.exs b/test/mobilizon/posts_test.exs
index 4f2b71cc9..f97383665 100644
--- a/test/mobilizon/posts_test.exs
+++ b/test/mobilizon/posts_test.exs
@@ -42,6 +42,29 @@ defmodule Mobilizon.PostsTest do
       end
     end
 
+    test "create_post/1 with an already existing URL returns error changeset" do
+      %Actor{} = actor = insert(:actor)
+      %Actor{} = group = insert(:group)
+
+      post_data =
+        Map.merge(@valid_attrs, %{
+          author_id: actor.id,
+          attributed_to_id: group.id,
+          url: "https://remote.tld/p/post"
+        })
+
+      {:ok, %Post{} = _post} = Posts.create_post(post_data)
+
+      assert {:error,
+              %Ecto.Changeset{
+                errors: [
+                  url:
+                    {"has already been taken",
+                     [constraint: :unique, constraint_name: "posts_url_index"]}
+                ]
+              }} = Posts.create_post(post_data)
+    end
+
     test "create_post/1 with invalid data returns error changeset" do
       assert {:error, %Ecto.Changeset{}} = Posts.create_post(@invalid_attrs)
     end